mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-03-11 07:11:01 +03:00
Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7d06de00fb | ||
|
|
6f8af9d114 | ||
|
|
0a672f092a | ||
|
|
ef62f1db29 | ||
|
|
7f804a0e45 | ||
|
|
b2dff336ce | ||
|
|
a6571e71e4 | ||
|
|
81f711eb00 | ||
|
|
c8a8e06558 | ||
|
|
2c079f53a9 | ||
|
|
322ffe288e | ||
|
|
c340eb0e57 | ||
|
|
4e953291ed | ||
|
|
1dea5fee0e | ||
|
|
9f24b46fee | ||
|
|
0808c41a1c | ||
|
|
296c6df462 | ||
|
|
13ee3e907d | ||
|
|
ce7d794b4c | ||
|
|
fb10069632 | ||
|
|
43a7677644 | ||
|
|
58fa32d7ea | ||
|
|
934d6c3987 | ||
|
|
2d7c6ef21f | ||
|
|
99a97e6a6c | ||
|
|
017a10e8c8 | ||
|
|
41ffa8ba08 | ||
|
|
e029d00cfa | ||
|
|
268534d5e7 | ||
|
|
a7d2bc63f9 | ||
|
|
559115c43c | ||
|
|
1277c7d60c | ||
|
|
9b69c7e972 | ||
|
|
a903f710ea | ||
|
|
b75f4daa47 | ||
|
|
fef44ffa57 | ||
|
|
5a812e3b2f | ||
|
|
910dcf2036 | ||
|
|
44a28aa5bd | ||
|
|
f7f947beb9 | ||
|
|
d03a9e2baf | ||
|
|
ca22316e95 | ||
|
|
ef99c479aa | ||
|
|
fa9260c763 | ||
|
|
fab11c8ffa | ||
|
|
9bd9658a92 | ||
|
|
213880c14d | ||
|
|
0550397046 | ||
|
|
f7a5a506f6 | ||
|
|
0f34c50bd2 | ||
|
|
055826e26f | ||
|
|
a30582c840 | ||
|
|
d106d97b99 | ||
|
|
d4410e78e2 | ||
|
|
e3fcc6cce3 | ||
|
|
265d08fc3b | ||
|
|
7c8329c5c6 | ||
|
|
d3947c9a19 | ||
|
|
cd99331668 | ||
|
|
472e18b10a | ||
|
|
d443f5de28 | ||
|
|
3242d132f6 | ||
|
|
e66d2facd4 | ||
|
|
f15b8dc0da | ||
|
|
3275824aec | ||
|
|
965cb704ec | ||
|
|
938e165470 | ||
|
|
9058ef3344 | ||
|
|
ed39cc3038 | ||
|
|
a77752c4cb | ||
|
|
c9940957f0 | ||
|
|
c90d72d720 | ||
|
|
2c30bd9d24 | ||
|
|
f2dc8e21a8 | ||
|
|
6a0da9cf09 | ||
|
|
d55974c352 | ||
|
|
a898c22f4b | ||
|
|
b82e8bedfc | ||
|
|
7453cefd94 | ||
|
|
1ed6b958cb | ||
|
|
57896ab176 | ||
|
|
5c370b3914 | ||
|
|
182e35adc7 | ||
|
|
d0a360fd80 | ||
|
|
2fbc0625de | ||
|
|
d3d20a4e20 | ||
|
|
2c088d3504 | ||
|
|
6f9728f2d4 | ||
|
|
30552fd202 | ||
|
|
9826c4e943 | ||
|
|
bb9445bd0f | ||
|
|
1f7e66f4cb | ||
|
|
2a34e918a0 | ||
|
|
21c0d924ab | ||
|
|
c8d5ee6565 | ||
|
|
3d8fc7ca7b | ||
|
|
246b5b93f8 | ||
|
|
2183c0980b | ||
|
|
4ae301710d | ||
|
|
5f9390c210 | ||
|
|
0f3a03aab7 | ||
|
|
02f455b0cc | ||
|
|
ffddf60184 | ||
|
|
482840b8bb | ||
|
|
a3637cf2b6 | ||
|
|
48669cdb34 | ||
|
|
a953845ba7 | ||
|
|
8d71534839 | ||
|
|
d110118961 | ||
|
|
fa1ed2bc0c | ||
|
|
3f28978dad | ||
|
|
02cd121465 | ||
|
|
5481c300b2 | ||
|
|
7b75257a4a | ||
|
|
c02e5cad73 | ||
|
|
dee03c0f9f | ||
|
|
d1159764f6 | ||
|
|
eacb07988d | ||
|
|
a375766ac2 | ||
|
|
9b9276e752 | ||
|
|
753a2ab2b7 | ||
|
|
0cef5f79ee | ||
|
|
b11a8dfe54 | ||
|
|
2d1c94f1ef | ||
|
|
e14e850e10 |
@@ -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.
|
|
||||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -81,9 +81,23 @@ jobs:
|
|||||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-22.04 }
|
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free Disk Space (Ubuntu)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
# jlumbroso/free-disk-space@main 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@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
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
65
.github/workflows/flutter-build.yml
vendored
65
.github/workflows/flutter-build.yml
vendored
@@ -39,13 +39,13 @@ env:
|
|||||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||||
VERSION: "1.4.2"
|
VERSION: "1.4.6"
|
||||||
NDK_VERSION: "r27c"
|
NDK_VERSION: "r27c"
|
||||||
#signing keys env variable checks
|
#signing keys env variable checks
|
||||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||||
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
|
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
|
||||||
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
|
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
|
||||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
|
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-bridge:
|
generate-bridge:
|
||||||
@@ -234,11 +234,11 @@ jobs:
|
|||||||
path: rustdesk
|
path: rustdesk
|
||||||
|
|
||||||
- name: Sign rustdesk files
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pip3 install requests argparse
|
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
|
- name: Build self-extracted executable
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -266,10 +266,10 @@ jobs:
|
|||||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||||
|
|
||||||
- name: Sign rustdesk self-extracted file
|
- 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
|
shell: bash
|
||||||
run: |
|
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
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -392,12 +392,19 @@ jobs:
|
|||||||
ls -l ./libs/portable/Runner.res;
|
ls -l ./libs/portable/Runner.res;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload unsigned
|
||||||
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
|
uses: actions/upload-artifact@master
|
||||||
|
with:
|
||||||
|
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||||
|
path: Release
|
||||||
|
|
||||||
- name: Sign rustdesk files
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pip3 install requests argparse
|
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
|
- name: Build self-extracted executable
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -411,10 +418,10 @@ jobs:
|
|||||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe
|
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe
|
||||||
|
|
||||||
- name: Sign rustdesk self-extracted file
|
- 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
|
shell: bash
|
||||||
run: |
|
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
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -437,7 +444,7 @@ jobs:
|
|||||||
- {
|
- {
|
||||||
arch: aarch64,
|
arch: aarch64,
|
||||||
target: aarch64-apple-ios,
|
target: aarch64-apple-ios,
|
||||||
os: macos-13,
|
os: macos-latest,
|
||||||
vcpkg-triplet: arm64-ios,
|
vcpkg-triplet: arm64-ios,
|
||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
@@ -555,7 +562,7 @@ jobs:
|
|||||||
job:
|
job:
|
||||||
- {
|
- {
|
||||||
target: x86_64-apple-darwin,
|
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: "",
|
extra-build-args: "",
|
||||||
arch: x86_64,
|
arch: x86_64,
|
||||||
vcpkg-triplet: x64-osx,
|
vcpkg-triplet: x64-osx,
|
||||||
@@ -616,7 +623,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install build runtime
|
- name: Install build runtime
|
||||||
run: |
|
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
|
# 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
|
if command -v pkg-config &>/dev/null; then
|
||||||
echo "pkg-config is already installed"
|
echo "pkg-config is already installed"
|
||||||
@@ -624,6 +631,17 @@ jobs:
|
|||||||
brew install pkg-config
|
brew install pkg-config
|
||||||
fi
|
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
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -756,6 +774,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-for-macOS
|
- build-for-macOS
|
||||||
- build-for-windows-flutter
|
- build-for-windows-flutter
|
||||||
|
- build-for-windows-sciter
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ inputs.upload-artifact }}
|
if: ${{ inputs.upload-artifact }}
|
||||||
steps:
|
steps:
|
||||||
@@ -777,9 +796,15 @@ jobs:
|
|||||||
name: rustdesk-unsigned-windows-x86_64
|
name: rustdesk-unsigned-windows-x86_64
|
||||||
path: ./windows-x86_64/
|
path: ./windows-x86_64/
|
||||||
|
|
||||||
|
- name: Download Artifacts
|
||||||
|
uses: actions/download-artifact@master
|
||||||
|
with:
|
||||||
|
name: rustdesk-unsigned-windows-x86
|
||||||
|
path: ./windows-x86/
|
||||||
|
|
||||||
- name: Combine unsigned app
|
- name: Combine unsigned app
|
||||||
run: |
|
run: |
|
||||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64
|
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||||
|
|
||||||
- name: Publish unsigned app
|
- name: Publish unsigned app
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -987,6 +1012,8 @@ jobs:
|
|||||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||||
run: |
|
run: |
|
||||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||||
|
# Increase Gradle JVM memory for CI builds
|
||||||
|
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
|
||||||
# temporary use debug sign config
|
# temporary use debug sign config
|
||||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||||
case ${{ matrix.job.target }} in
|
case ${{ matrix.job.target }} in
|
||||||
@@ -1194,6 +1221,8 @@ jobs:
|
|||||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||||
run: |
|
run: |
|
||||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||||
|
# Increase Gradle JVM memory for CI builds
|
||||||
|
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
|
||||||
# temporary use debug sign config
|
# temporary use debug sign config
|
||||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||||
mv ./flutter/android/app/src/main/jniLibs/arm64-v8a/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
|
mv ./flutter/android/app/src/main/jniLibs/arm64-v8a/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
|
||||||
@@ -1429,7 +1458,8 @@ jobs:
|
|||||||
rpm \
|
rpm \
|
||||||
unzip \
|
unzip \
|
||||||
wget \
|
wget \
|
||||||
xz-utils
|
xz-utils \
|
||||||
|
libssl-dev
|
||||||
# we have libopus compiled by us.
|
# we have libopus compiled by us.
|
||||||
apt-get remove -y libopus-dev || true
|
apt-get remove -y libopus-dev || true
|
||||||
# output devs
|
# output devs
|
||||||
@@ -1709,12 +1739,13 @@ jobs:
|
|||||||
unzip \
|
unzip \
|
||||||
wget \
|
wget \
|
||||||
xz-utils \
|
xz-utils \
|
||||||
zip
|
zip \
|
||||||
|
libssl-dev
|
||||||
# arm-linux needs CMake and vcokg built from source as there
|
# arm-linux needs CMake and vcokg built from source as there
|
||||||
# are no prebuilts available from Kitware and Microsoft
|
# are no prebuilts available from Kitware and Microsoft
|
||||||
if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then
|
if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then
|
||||||
# install gcc/g++ 8 for vcpkg and OpenSSL headers for CMake
|
# install gcc/g++ 8 for vcpkg and OpenSSL headers for CMake
|
||||||
apt-get install -y gcc-8 g++-8 libssl-dev
|
apt-get install -y gcc-8 g++-8
|
||||||
# bootstrap CMake amd add it to PATH
|
# bootstrap CMake amd add it to PATH
|
||||||
git clone --depth 1 https://github.com/kitware/cmake -b "v${{ env.SCITER_ARMV7_CMAKE_VERSION }}" /tmp/cmake
|
git clone --depth 1 https://github.com/kitware/cmake -b "v${{ env.SCITER_ARMV7_CMAKE_VERSION }}" /tmp/cmake
|
||||||
pushd /tmp/cmake
|
pushd /tmp/cmake
|
||||||
|
|||||||
2
.github/workflows/playground.yml
vendored
2
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
|||||||
TAG_NAME: "nightly"
|
TAG_NAME: "nightly"
|
||||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||||
VERSION: "1.4.2"
|
VERSION: "1.4.6"
|
||||||
NDK_VERSION: "r26d"
|
NDK_VERSION: "r26d"
|
||||||
#signing keys env variable checks
|
#signing keys env variable checks
|
||||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||||
|
|||||||
4
.github/workflows/winget.yml
vendored
4
.github/workflows/winget.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
|||||||
- uses: vedantmgoyal9/winget-releaser@main
|
- uses: vedantmgoyal9/winget-releaser@main
|
||||||
with:
|
with:
|
||||||
identifier: RustDesk.RustDesk
|
identifier: RustDesk.RustDesk
|
||||||
version: "1.4.2"
|
version: "1.4.6"
|
||||||
release-tag: "1.4.2"
|
release-tag: "1.4.6"
|
||||||
token: ${{ secrets.WINGET_TOKEN }}
|
token: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
libsciter-gtk.so
|
libsciter-gtk.so
|
||||||
src/ui/inline.rs
|
src/ui/inline.rs
|
||||||
extractor
|
extractor
|
||||||
|
|||||||
1540
Cargo.lock
generated
1540
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustdesk"
|
name = "rustdesk"
|
||||||
version = "1.4.2"
|
version = "1.4.6"
|
||||||
authors = ["rustdesk <info@rustdesk.com>"]
|
authors = ["rustdesk <info@rustdesk.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build= "build.rs"
|
build= "build.rs"
|
||||||
@@ -76,13 +76,14 @@ crossbeam-queue = "0.3"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
cidr-utils = "0.5"
|
cidr-utils = "0.5"
|
||||||
libloading = "0.8"
|
|
||||||
fon = "0.6"
|
fon = "0.6"
|
||||||
zip = "0.6"
|
zip = "0.6"
|
||||||
shutdown_hooks = "0.1"
|
shutdown_hooks = "0.1"
|
||||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||||
stunclient = "0.4"
|
stunclient = "0.4"
|
||||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||||
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
|
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
|
||||||
@@ -121,10 +122,19 @@ winapi = { version = "0.3", features = [
|
|||||||
] }
|
] }
|
||||||
windows = { version = "0.61", features = [
|
windows = { version = "0.61", features = [
|
||||||
"Win32",
|
"Win32",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System",
|
"Win32_System",
|
||||||
"Win32_System_Diagnostics",
|
"Win32_System_Diagnostics",
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_Diagnostics_ToolHelp",
|
"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"
|
winreg = "0.11"
|
||||||
windows-service = "0.6"
|
windows-service = "0.6"
|
||||||
@@ -165,14 +175,8 @@ fontdb = "0.23"
|
|||||||
bytemuck = "1.23"
|
bytemuck = "1.23"
|
||||||
ttf-parser = "0.25"
|
ttf-parser = "0.25"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
|
||||||
# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
|
|
||||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "native-tls", "gzip"], default-features=false }
|
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
|
|
||||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libxdo-sys = "0.11"
|
||||||
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
||||||
pulse = { package = "libpulse-binding", version = "2.27" }
|
pulse = { package = "libpulse-binding", version = "2.27" }
|
||||||
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
||||||
@@ -181,7 +185,6 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
|||||||
dbus = "0.9"
|
dbus = "0.9"
|
||||||
dbus-crossroads = "0.5"
|
dbus-crossroads = "0.5"
|
||||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
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}
|
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||||
percent-encoding = {version = "2.3", optional = true}
|
percent-encoding = {version = "2.3", optional = true}
|
||||||
@@ -192,6 +195,9 @@ termios = "0.3"
|
|||||||
terminfo = "0.8"
|
terminfo = "0.8"
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||||
|
openssl = { version = "0.10", features = ["vendored"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.13"
|
android_logger = "0.13"
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
@@ -201,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"]
|
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"]
|
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]
|
[package.metadata.winres]
|
||||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||||
ProductName = "RustDesk"
|
ProductName = "RustDesk"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<a href="#how-to-build-with-docker">Docker</a> •
|
<a href="#how-to-build-with-docker">Docker</a> •
|
||||||
<a href="#file-structure">Structure</a> •
|
<a href="#file-structure">Structure</a> •
|
||||||
<a href="#snapshot">Snapshot</a><br>
|
<a href="#snapshot">Snapshot</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>]<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>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.2
|
version: 1.4.6
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.2
|
version: 1.4.6
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
2
build.py
2
build.py
@@ -299,7 +299,7 @@ Version: %s
|
|||||||
Architecture: %s
|
Architecture: %s
|
||||||
Maintainer: rustdesk <info@rustdesk.com>
|
Maintainer: rustdesk <info@rustdesk.com>
|
||||||
Homepage: https://rustdesk.com
|
Homepage: https://rustdesk.com
|
||||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3, 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-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||||
Recommends: libayatana-appindicator3-1
|
Recommends: libayatana-appindicator3-1
|
||||||
Description: A remote control software.
|
Description: A remote control software.
|
||||||
|
|
||||||
|
|||||||
2
build.rs
2
build.rs
@@ -18,7 +18,7 @@ fn build_mac() {
|
|||||||
b.flag("-DNO_InputMonitoringAuthStatus=1");
|
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);
|
println!("cargo:rerun-if-changed={}", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
docs/CODE_OF_CONDUCT-RO.md
Normal file
85
docs/CODE_OF_CONDUCT-RO.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Codul de Conduită al Contributorilor
|
||||||
|
|
||||||
|
## Angajamentul Nostru
|
||||||
|
|
||||||
|
Noi, ca membri, contribuitori și lideri, ne angajăm să facem ca participarea în comunitatea noastră să fie o experiență fără hărțuire pentru toată lumea, indiferent de vârstă, dimensiunea corpului, dizabilități vizibile sau invizibile, etnie, caracteristici sexuale, identitate și exprimare de gen, nivel de experiență, educație, statut socio-economic, naționalitate, aspect personal, rasă, religie sau identitate și orientare sexuală.
|
||||||
|
|
||||||
|
Ne angajăm să acționăm și să interacționăm în moduri care contribuie la o comunitate deschisă, primitoare, diversă, incluzivă și sănătoasă.
|
||||||
|
|
||||||
|
## Standardele Noastre
|
||||||
|
|
||||||
|
Exemple de comportamente care contribuie la un mediu pozitiv pentru comunitatea noastră includ:
|
||||||
|
|
||||||
|
* Demonstrarea empatiei și a bunătății față de ceilalți
|
||||||
|
* Respectarea opiniilor, punctelor de vedere și experiențelor diferite
|
||||||
|
* Oferirea și acceptarea cu grație a feedback-ului constructiv
|
||||||
|
* Asumarea responsabilității și cererea de scuze celor afectați de greșelile noastre și învățarea din experiență
|
||||||
|
* Concentrarea pe ceea ce este cel mai bun nu doar pentru noi ca indivizi, ci pentru întreaga comunitate
|
||||||
|
|
||||||
|
Exemple de comportamente inacceptabile includ:
|
||||||
|
|
||||||
|
* Utilizarea limbajului sau imaginilor sexualizate, precum și atenția sau avansurile sexuale de orice fel
|
||||||
|
* Trollare, insulte sau comentarii denigratoare și atacuri personale sau politice
|
||||||
|
* Hărțuire publică sau privată
|
||||||
|
* Publicarea informațiilor private ale altora, cum ar fi adresa fizică sau de e-mail, fără permisiunea explicită
|
||||||
|
* Alte comportamente care ar putea fi considerate inadecvate într-un cadru profesional
|
||||||
|
|
||||||
|
## Responsabilități de Aplicare
|
||||||
|
|
||||||
|
Liderii comunității sunt responsabili pentru clarificarea și aplicarea standardelor noastre de comportament acceptabil și vor lua măsuri corective adecvate și echitabile ca răspuns la orice comportament pe care îl consideră inadecvat, amenințător, ofensator sau dăunător.
|
||||||
|
|
||||||
|
Liderii comunității au dreptul și responsabilitatea de a elimina, edita sau respinge comentarii, commit-uri, cod, editări wiki, probleme și alte contribuții care nu se aliniază acestui Cod de Conduită și vor comunica motivele pentru deciziile de moderare atunci când este cazul.
|
||||||
|
|
||||||
|
## Domeniu de Aplicare
|
||||||
|
|
||||||
|
Acest Cod de Conduită se aplică în toate spațiile comunității și se aplică și atunci când un individ reprezintă oficial comunitatea în spații publice.
|
||||||
|
Exemple de reprezentare a comunității includ utilizarea unei adrese de e-mail oficiale, postarea printr-un cont oficial de social media sau acționarea ca reprezentant desemnat la un eveniment online sau offline.
|
||||||
|
|
||||||
|
## Aplicare
|
||||||
|
|
||||||
|
Cazurile de comportament abuziv, hărțuitor sau altfel inacceptabil pot fi raportate liderilor comunității responsabili pentru aplicare la [info@rustdesk.com](mailto:info@rustdesk.com).
|
||||||
|
Toate plângerile vor fi revizuite și investigate prompt și corect.
|
||||||
|
|
||||||
|
Toți liderii comunității sunt obligați să respecte confidențialitatea și securitatea persoanei care raportează orice incident.
|
||||||
|
|
||||||
|
## Ghiduri de Aplicare
|
||||||
|
|
||||||
|
Liderii comunității vor urma aceste Ghiduri privind Impactul Comunității pentru a stabili consecințele pentru orice acțiune pe care o consideră o încălcare a acestui Cod de Conduită:
|
||||||
|
|
||||||
|
### 1. Corectare
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Utilizarea limbajului neadecvat sau alte comportamente considerate neprofesionale sau nedorite în comunitate.
|
||||||
|
|
||||||
|
**Consecință**: O avertizare scrisă și privată din partea liderilor comunității, oferind claritate asupra naturii încălcării și o explicație despre motivul pentru care comportamentul a fost inadecvat. Poate fi cerută o scuză publică.
|
||||||
|
|
||||||
|
### 2. Avertisment
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Încălcare printr-un incident singular sau o serie de acțiuni.
|
||||||
|
|
||||||
|
**Consecință**: Un avertisment cu consecințe pentru continuarea comportamentului. Nicio interacțiune cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, pentru o perioadă specificată. Aceasta include evitarea interacțiunilor în spațiile comunității, precum și pe canale externe, cum ar fi rețelele sociale. Încălcarea acestor termeni poate duce la o suspendare temporară sau permanentă.
|
||||||
|
|
||||||
|
### 3. Suspendare Temporară
|
||||||
|
|
||||||
|
**Impact asupra comunității**: O încălcare serioasă a standardelor comunității, inclusiv comportament neadecvat susținut.
|
||||||
|
|
||||||
|
**Consecință**: Suspendare temporară de la orice tip de interacțiune sau comunicare publică cu comunitatea pentru o perioadă specificată. Nicio interacțiune publică sau privată cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, nu este permisă în această perioadă. Încălcarea acestor termeni poate duce la o interdicție permanentă.
|
||||||
|
|
||||||
|
### 4. Interdicție Permanentă
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Demonstrând un tipar de încălcare a standardelor comunității, inclusiv comportament neadecvat susținut, hărțuire a unei persoane sau agresiune față de sau denigrare a unor grupuri de persoane.
|
||||||
|
|
||||||
|
**Consecință**: Interdicție permanentă de la orice tip de interacțiune publică în cadrul comunității.
|
||||||
|
|
||||||
|
## Atribuire
|
||||||
|
|
||||||
|
Acest Cod de Conduită este adaptat din [Contributor Covenant][homepage], versiunea 2.0, disponibil la [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||||
|
|
||||||
|
Ghidurile privind Impactul Comunității au fost inspirate de [scara de aplicare a codului de conduită Mozilla][Mozilla CoC].
|
||||||
|
|
||||||
|
Pentru răspunsuri la întrebări frecvente despre acest cod de conduită, vezi FAQ la [https://www.contributor-covenant.org/faq][FAQ]. Traduceri sunt disponibile la [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
|
||||||
31
docs/CONTRIBUTING-RO.md
Normal file
31
docs/CONTRIBUTING-RO.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contribuții la RustDesk
|
||||||
|
|
||||||
|
RustDesk primește cu plăcere contribuții din partea tuturor. Iată ghidurile dacă te gândești să ne ajuți:
|
||||||
|
|
||||||
|
## Contribuții
|
||||||
|
|
||||||
|
Contribuțiile la RustDesk sau la dependențele sale ar trebui făcute sub forma de pull request-uri pe GitHub. Fiecare pull request va fi revizuit de un contributor principal (cineva cu permisiunea de a aplica patch-uri) și fie va fi integrat în arborele principal, fie vor fi oferite sugestii pentru modificările necesare. Toate contribuțiile trebuie să urmeze acest format, chiar și cele ale contributorilor principali.
|
||||||
|
|
||||||
|
Dacă dorești să lucrezi la o problemă, te rugăm să o revendici mai întâi comentând pe GitHub issue-ul pe care vrei să lucrezi. Aceasta previne eforturi duplicate din partea contributorilor asupra aceleiași probleme.
|
||||||
|
|
||||||
|
## Lista de verificare pentru Pull Request
|
||||||
|
|
||||||
|
- Creează un branch din branch-ul `master` și, dacă este necesar, fă rebase la branch-ul `master` curent înainte de a trimite pull request-ul. Dacă nu se poate integra curat cu `master`, ți se poate cere să faci rebase la modificările tale.
|
||||||
|
|
||||||
|
- Commit-urile ar trebui să fie cât mai mici posibil, asigurând totodată că fiecare commit este corect independent (adică fiecare commit ar trebui să compileze și să treacă testele).
|
||||||
|
|
||||||
|
- Commit-urile trebuie să fie însoțite de un semnătura Developer Certificate of Origin (http://developercertificate.org), care indică faptul că tu (și angajatorul tău, dacă este cazul) ești de acord să respecți termenii [licenței proiectului](../LICENCE). În git, aceasta este opțiunea `-s` la `git commit`.
|
||||||
|
|
||||||
|
- Dacă patch-ul tău nu este revizuit sau ai nevoie ca o anumită persoană să-l revizuiască, poți @-reply unui reviewer cerând o revizuire în pull request sau într-un comentariu, sau poți solicita o revizuire prin [email](mailto:info@rustdesk.com).
|
||||||
|
|
||||||
|
- Adaugă teste relevante pentru bug-ul corectat sau pentru funcționalitatea nouă.
|
||||||
|
|
||||||
|
Pentru instrucțiuni specifice git, vezi [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||||
|
|
||||||
|
## Conduită
|
||||||
|
|
||||||
|
[Codul de Conduită RustDesk](https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md)
|
||||||
|
|
||||||
|
## Comunicare
|
||||||
|
|
||||||
|
Contributorii RustDesk frecventează [Discord](https://discord.gg/nDceKgxnkV).
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Dein Remote-Desktop"><br>
|
||||||
<a href="#freie-öffentliche-server">Server</a> •
|
|
||||||
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
||||||
<a href="#auf-docker-kompilieren">Docker</a> •
|
<a href="#auf-docker-kompilieren">Docker</a> •
|
||||||
<a href="#dateistruktur">Dateistruktur</a> •
|
<a href="#dateistruktur">Dateistruktur</a> •
|
||||||
<a href="#screenshots">Screenshots</a><br>
|
<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>
|
<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>
|
</p>
|
||||||
|
|
||||||
> [!Vorsicht]
|
> [!Caution]
|
||||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
> **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.
|
> 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)
|
[**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"
|
alt="Get it on F-Droid"
|
||||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
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
|
## Abhängigkeiten
|
||||||
|
|
||||||
@@ -64,18 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
|||||||
```sh
|
```sh
|
||||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
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 \
|
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
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
```sh
|
```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)
|
### Fedora 28 (CentOS 8)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch (Manjaro)
|
### Arch (Manjaro)
|
||||||
@@ -114,7 +117,7 @@ cd
|
|||||||
```sh
|
```sh
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
source $HOME/.cargo/env
|
source $HOME/.cargo/env
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
mkdir -p target/debug
|
mkdir -p target/debug
|
||||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
@@ -129,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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/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/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/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/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/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
|
- **[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
|
## Screenshots
|
||||||
|
|
||||||

|

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

|

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

|

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

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

|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
|
|||||||
|
|
||||||
[](https://rustdesk.com/pricing.html)
|
[](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
|
## 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) |
|
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
|
|||||||
181
docs/README-RO.md
Normal file
181
docs/README-RO.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="../res/logo-header.svg" alt="RustDesk - desktopul tău la distanță"><br>
|
||||||
|
<a href="../README.md#raw-steps-to-build">Construire</a> •
|
||||||
|
<a href="../README.md#how-to-build-with-docker">Docker</a> •
|
||||||
|
<a href="../README.md#file-structure">Structură</a> •
|
||||||
|
<a href="../README.md#snapshot">Capturi</a><br>
|
||||||
|
[<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>Avem nevoie de ajutorul tău pentru a traduce acest README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> și <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> în limba ta maternă</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> [!Atenție]
|
||||||
|
> **Declinare de responsabilitate privind utilizarea abuzivă:** <br>
|
||||||
|
> Dezvoltatorii RustDesk nu susțin sau aprobă utilizarea neetică sau ilegală a acestui software. Utilizarea abuzivă, cum ar fi accesul neautorizat, controlul sau invadarea intimității, este strict împotriva regulilor noastre. Autorii nu sunt responsabili pentru utilizarea necorespunzătoare a aplicației.
|
||||||
|
|
||||||
|
|
||||||
|
Conversați cu noi: [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)
|
||||||
|
|
||||||
|
Încă o soluție de desktop la distanță scrisă în Rust. Funcționează imediat, fără configurare necesară. Ai control total asupra datelor tale, fără probleme de securitate. Poți folosi serverul nostru de rendezvous/relay, [să-ți configurezi propriul server](https://rustdesk.com/server) sau [să scrii propriul server de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CONTRIBUTING.md) pentru ajutor la început.
|
||||||
|
|
||||||
|
[**ÎNTREBĂRI FRECVENTE (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
|
[**DESCĂRCARE BINARE**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
|
[**BUILD NIGHTLY**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
|
[<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)
|
||||||
|
|
||||||
|
## Dependențe
|
||||||
|
|
||||||
|
Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) pentru construire cu Flutter.
|
||||||
|
|
||||||
|
Te rugăm să descarci singur librăria dinamică Sciter.
|
||||||
|
|
||||||
|
[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)
|
||||||
|
|
||||||
|
## Pași pentru construire (Raw Steps to build)
|
||||||
|
|
||||||
|
- Pregătește mediul de dezvoltare Rust și mediul de construire C++
|
||||||
|
|
||||||
|
- Instalează [vcpkg](https://github.com/microsoft/vcpkg) și setează corect variabila de mediu `VCPKG_ROOT`
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- rulează `cargo run`
|
||||||
|
|
||||||
|
## [Construire](https://rustdesk.com/docs/en/dev/build/)
|
||||||
|
|
||||||
|
## Cum se construiește pe Linux
|
||||||
|
|
||||||
|
### Ubuntu 18 (Debian 10)
|
||||||
|
|
||||||
|
```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 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 gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch (Manjaro)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instalează vcpkg
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/microsoft/vcpkg
|
||||||
|
cd vcpkg
|
||||||
|
git checkout 2023.04.15
|
||||||
|
cd ..
|
||||||
|
vcpkg/bootstrap-vcpkg.sh
|
||||||
|
export VCPKG_ROOT=$HOME/vcpkg
|
||||||
|
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repară libvpx (Pentru Fedora)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vcpkg/buildtrees/libvpx/src
|
||||||
|
cd *
|
||||||
|
./configure
|
||||||
|
sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile
|
||||||
|
sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile
|
||||||
|
make
|
||||||
|
cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||||
|
cd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
source $HOME/.cargo/env
|
||||||
|
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
|
||||||
|
mv libsciter-gtk.so target/debug
|
||||||
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cum să construiești cu Docker
|
||||||
|
|
||||||
|
Începe prin clonarea repository-ului și construirea imaginii Docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
|
docker build -t "rustdesk-builder" .
|
||||||
|
```
|
||||||
|
|
||||||
|
Apoi, de fiecare dată când trebuie să construiești aplicația, rulează comanda următoare:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Reține că prima construire poate dura mai mult până când dependențele sunt în cache; construirile ulterioare vor fi mai rapide. De asemenea, dacă trebuie să specifici argumente diferite comenzii de build, le poți adăuga la finalul comenzii în poziția `<OPTIONAL-ARGS>`. De exemplu, pentru a construi o versiune optimizată de release, adaugă `--release`. Executabilul rezultat va fi disponibil în folderul `target` pe sistemul tău, și poate fi rulat cu:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
target/debug/rustdesk
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau, dacă rulezi un executabil release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
target/release/rustdesk
|
||||||
|
```
|
||||||
|
|
||||||
|
Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, altfel aplicația poate să nu găsească resursele necesare. De asemenea, reține că alte subcomenzi cargo, cum ar fi `install` sau `run`, nu sunt acceptate în prezent prin această metodă, deoarece ar instala sau rula programul în interiorul containerului în loc de gazdă.
|
||||||
|
|
||||||
|
## Structura fișierelor
|
||||||
|
|
||||||
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec video, config, wrapper tcp/udp, protobuf, funcții fs pentru transfer de fișiere și alte funcții utilitare
|
||||||
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: capturare ecran
|
||||||
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: control tastatură/mouse specific platformei
|
||||||
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementare copy/paste pentru fișiere pentru Windows, Linux, macOS.
|
||||||
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interfață Sciter învechită (depreciată)
|
||||||
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: servicii audio/clipboard/input/video și conexiuni de rețea
|
||||||
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inițiază o conexiune peer
|
||||||
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
|
||||||
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
|
||||||
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
|
||||||
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
|
||||||
|
|
||||||
|
## Capturi de ecran
|
||||||
|
|
||||||
|

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

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

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

|
||||||
@@ -167,7 +167,7 @@ target/release/rustdesk
|
|||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
|
- **[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/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)**: специфичный для платформы код
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
||||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
- **[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
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
||||||
|
|||||||
@@ -7,34 +7,37 @@
|
|||||||
<a href="#file-structure">Dosya Yapısı</a> •
|
<a href="#file-structure">Dosya Yapısı</a> •
|
||||||
<a href="#snapshot">Ekran Görüntüleri</a><br>
|
<a href="#snapshot">Ekran Görüntüleri</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>]<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>]<br>
|
||||||
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Belge</a>'sini ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Dökümantasyonu</a>'nu ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
> [!Dikkat]
|
||||||
|
> **Yanlış Kullanım Uyarısı:** <br>
|
||||||
|
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
|
||||||
|
|
||||||
Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
Bizimle sohbet edin: [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)
|
||||||
|
|
||||||
Başka bir uzak masaüstü yazılımı daha, Rust dilinde yazılmış. Hemen kullanıma hazır, hiçbir yapılandırma gerektirmez. Verilerinizin tam kontrolünü elinizde tutarsınız ve güvenlikle ilgili endişeleriniz olmaz. Kendi buluş/iletme sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi buluş/iletme sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
Rust dilinde yazılmış, başka bir uzak masaüstü yazılımı daha. Hiçbir yapılandırma gerekmeksizin, hemen kullanıma hazır. Güvenlik konusunda hiçbir endişe duymadan, verileriniz üzerinde tam kontrole sahip olun. Kendi rendezvous/relay sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi rendezvous/relay sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
RustDesk, herkesten katkıyı kabul eder. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
||||||
|
|
||||||
[**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
[**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
[**BİNARİ İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
[**BINARY İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
[**NİGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
[**NIGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||||
alt="F-Droid'de Alın"
|
alt="F-Droid'de Alın"
|
||||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
|
||||||
## Bağımlılıklar
|
## Gereksinimler
|
||||||
|
|
||||||
Masaüstü sürümleri GUI için
|
Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Flutter sürümünü derlemek için [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)'ımıza bakın.
|
||||||
|
|
||||||
[Sciter](https://sciter.com/) veya Flutter kullanır, bu kılavuz sadece Sciter içindir.
|
|
||||||
|
|
||||||
Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
|||||||
|
|
||||||
- Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın.
|
- Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın.
|
||||||
|
|
||||||
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` çevresel değişkenini doğru bir şekilde ayarlayın.
|
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` ortam değişkenini doğru bir şekilde ayarlayın.
|
||||||
|
|
||||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||||
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
||||||
@@ -123,7 +126,7 @@ VCPKG_ROOT=$HOME/vcpkg cargo run
|
|||||||
|
|
||||||
## Docker ile Derleme Nasıl Yapılır
|
## Docker ile Derleme Nasıl Yapılır
|
||||||
|
|
||||||
Öncelikle deposunu klonlayın ve Docker konteynerini oluşturun:
|
Önce repository'i klonlayın ve Docker container'ını oluşturun.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
@@ -131,44 +134,40 @@ cd rustdesk
|
|||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
Ardından, uygulamayı derlemek için her seferinde aşağıdaki komutu çalıştırın:
|
Ardından, uygulamayı her derlemeniz gerektiğinde aşağıdaki komutu çalıştırın:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||||
```
|
```
|
||||||
|
|
||||||
İlk derleme, bağımlılıklar önbelleğe alınmadan önce daha uzun sürebilir, sonraki derlemeler daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu
|
Bilin ki ilk derlemeniz gereksinimlerin önbelleği yüklenmesinden ötürü uzun sürebilir, sonraki derlemeleriniz daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu komutun sonunda ki `<OPTIONAL-ARGS>` yerine yazabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan çalıştırılabilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir olacaktır:
|
||||||
|
|
||||||
komutun sonunda `<İSTEĞE BAĞLI-ARGÜMANLAR>` pozisyonunda yapabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan yürütülebilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/debug/rustdesk
|
target/debug/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Veya, yayın yürütülebilir dosyası çalıştırılıyorsa:
|
Veya, yayım çalıştırılabilir dosyası için:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/release/rustdesk
|
target/release/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır ve ana makinede değil.
|
Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır, ana makinede değil.
|
||||||
|
|
||||||
## Dosya Yapısı
|
## Dosya Yapısı
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodlayıcı, yapılandırma, tcp/udp sarmalayıcı, protobuf, dosya transferi için fs işlevleri ve diğer bazı yardımcı işlevler
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, dosya transferi için fs fonksiyonları ve diğer bazı yardımcı işlevler
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama
|
||||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü
|
||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: platforma özgü kopyala/yapıştır implementasyonları.
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pasta/klavye/video hizmetleri ve ağ bağlantıları
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: Eski Sciter UI (kaldırılacak)
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: bir eş bağlantısı başlatır
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pano/input/video servisleri ve ağ bağlantıları
|
||||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişim kurar, uzak doğrudan (TCP delik vurma) veya iletme bağlantısını bekler
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Eşli bağlantı başlat
|
||||||
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
|
||||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
||||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
|
||||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
|
||||||
|
|
||||||
> [!Dikkat]
|
|
||||||
> **Yanlış Kullanım Uyarısı:** <br>
|
|
||||||
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
|
|
||||||
|
|
||||||
## Ekran Görüntüleri
|
## Ekran Görüntüleri
|
||||||
|
|
||||||
|
|||||||
9
docs/SECURITY-RO.md
Normal file
9
docs/SECURITY-RO.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Politica de Securitate
|
||||||
|
|
||||||
|
## Raportarea unei Vulnerabilități
|
||||||
|
|
||||||
|
Acordăm o mare importanță securității proiectului. Încurajăm toți utilizatorii să ne raporteze orice vulnerabilități pe care le descoperă.
|
||||||
|
Dacă găsești o vulnerabilitate de securitate în proiectul RustDesk, te rugăm să o raportezi responsabil trimițând un e-mail la info@rustdesk.com.
|
||||||
|
|
||||||
|
În acest moment, nu avem un program de recompense pentru descoperirea de bug-uri. Suntem o echipă mică care încearcă să rezolve o problemă mare.
|
||||||
|
Te rugăm să raportezi orice vulnerabilitate în mod responsabil, astfel încât să putem continua să construim o aplicație sigură pentru întreaga comunitate.
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
],
|
],
|
||||||
"finish-args": [
|
"finish-args": [
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--socket=fallback-x11",
|
|
||||||
"--socket=wayland",
|
"--socket=wayland",
|
||||||
|
"--socket=x11",
|
||||||
"--share=network",
|
"--share=network",
|
||||||
"--filesystem=home",
|
"--filesystem=home",
|
||||||
"--device=dri",
|
"--device=dri",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import com.google.protobuf.gradle.*
|
import com.google.protobuf.gradle.*
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "com.google.protobuf" version "0.9.4"
|
id "com.google.protobuf" version "0.9.4"
|
||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
@@ -30,8 +32,37 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = '1.0'
|
flutterVersionName = '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
// Add rustls-platform-verifier Android support
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
String findRustlsPlatformVerifierMavenDir() {
|
||||||
|
def dependencyText = providers.exec {
|
||||||
|
it.workingDir = new File("../..")
|
||||||
|
commandLine("cargo", "metadata", "--format-version", "1")
|
||||||
|
}.standardOutput.asText.get()
|
||||||
|
|
||||||
|
def dependencyJson = new JsonSlurper().parseText(dependencyText)
|
||||||
|
def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" }
|
||||||
|
|
||||||
|
if (pkg == null) {
|
||||||
|
throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!")
|
||||||
|
}
|
||||||
|
|
||||||
|
def manifestPath = file(pkg.manifest_path)
|
||||||
|
def mavenDir = new File(manifestPath.parentFile, "maven")
|
||||||
|
|
||||||
|
if (!mavenDir.exists()) {
|
||||||
|
throw new GradleException("Maven directory not found at: ${mavenDir.path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}")
|
||||||
|
return mavenDir.path
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = findRustlsPlatformVerifierMavenDir()
|
||||||
|
metadataSources.artifact()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
@@ -67,7 +98,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId "com.carriez.flutter_hbb"
|
applicationId "com.carriez.flutter_hbb"
|
||||||
minSdkVersion 21
|
minSdkVersion 22
|
||||||
targetSdkVersion 33
|
targetSdkVersion 33
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
@@ -97,8 +128,10 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||||
implementation "androidx.media:media:1.6.0"
|
implementation "androidx.media:media:1.6.0"
|
||||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation "rustls:rustls-platform-verifier:0.1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,6 @@
|
|||||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||||
<fields>;
|
<fields>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keep rustls-platform-verifier classes for JNI
|
||||||
|
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".MainApplication"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="RustDesk"
|
android:label="RustDesk"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
channelTag
|
channelTag
|
||||||
)
|
)
|
||||||
initFlutterChannel(flutterMethodChannel!!)
|
initFlutterChannel(flutterMethodChannel!!)
|
||||||
thread { setCodecInfo() }
|
thread {
|
||||||
|
try {
|
||||||
|
setCodecInfo()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Failed to setCodecInfo: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.carriez.flutter_hbb
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import ffi.FFI
|
||||||
|
|
||||||
|
class MainApplication : Application() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainApplication"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "App start")
|
||||||
|
FFI.onAppStart(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ object FFI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
external fun init(ctx: Context)
|
external fun init(ctx: Context)
|
||||||
|
external fun onAppStart(ctx: Context)
|
||||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||||
external fun startServer(app_dir: String, custom_client_config: String)
|
external fun startServer(app_dir: String, custom_client_config: String)
|
||||||
external fun startService()
|
external fun startService()
|
||||||
|
|||||||
@@ -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 |
@@ -43,6 +43,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -60,6 +62,8 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<true/>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
|||||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
import 'package:flutter_hbb/main.dart';
|
import 'package:flutter_hbb/main.dart';
|
||||||
import 'package:flutter_hbb/models/peer_model.dart';
|
import 'package:flutter_hbb/models/peer_model.dart';
|
||||||
|
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:uni_links/uni_links.dart';
|
import 'package:uni_links/uni_links.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:window_size/window_size.dart' as window_size;
|
import 'package:window_size/window_size.dart' as window_size;
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ import 'package:flutter_hbb/native/win32.dart'
|
|||||||
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
||||||
import 'package:flutter_hbb/native/common.dart'
|
import 'package:flutter_hbb/native/common.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||||
|
|
||||||
final globalKey = GlobalKey<NavigatorState>();
|
final globalKey = GlobalKey<NavigatorState>();
|
||||||
final navigationBarKey = GlobalKey();
|
final navigationBarKey = GlobalKey();
|
||||||
@@ -75,6 +78,9 @@ bool _ignoreDevicePixelRatio = true;
|
|||||||
int windowsBuildNumber = 0;
|
int windowsBuildNumber = 0;
|
||||||
DesktopType? desktopType;
|
DesktopType? desktopType;
|
||||||
|
|
||||||
|
// Tolerance used for floating-point position comparisons to avoid precision errors.
|
||||||
|
const double _kPositionEpsilon = 1e-6;
|
||||||
|
|
||||||
bool get isMainDesktopWindow =>
|
bool get isMainDesktopWindow =>
|
||||||
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
||||||
|
|
||||||
@@ -106,6 +112,10 @@ enum DesktopType {
|
|||||||
portForward,
|
portForward,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isDoubleEqual(double a, double b) {
|
||||||
|
return (a - b).abs() < _kPositionEpsilon;
|
||||||
|
}
|
||||||
|
|
||||||
class IconFont {
|
class IconFont {
|
||||||
static const _family1 = 'Tabbar';
|
static const _family1 = 'Tabbar';
|
||||||
static const _family2 = 'PeerSearchbar';
|
static const _family2 = 'PeerSearchbar';
|
||||||
@@ -1001,13 +1011,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;
|
final overlayState = globalKey.currentState?.overlay;
|
||||||
if (overlayState == null) return;
|
if (overlayState == null) return;
|
||||||
final entry = OverlayEntry(builder: (context) {
|
final entry = OverlayEntry(builder: (context) {
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: const Alignment(0.0, 0.8),
|
alignment: alignment,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: MyTheme.color(context).toastBg,
|
color: MyTheme.color(context).toastBg,
|
||||||
@@ -1112,18 +1124,23 @@ class CustomAlertDialog extends StatelessWidget {
|
|||||||
|
|
||||||
Widget createDialogContent(String text) {
|
Widget createDialogContent(String text) {
|
||||||
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
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 = [];
|
final List<TextSpan> spans = [];
|
||||||
int start = 0;
|
int start = 0;
|
||||||
bool hasLink = false;
|
|
||||||
|
|
||||||
linkRegExp.allMatches(text).forEach((match) {
|
linkRegExp.allMatches(text).forEach((match) {
|
||||||
hasLink = true;
|
|
||||||
if (match.start > start) {
|
if (match.start > start) {
|
||||||
spans.add(TextSpan(text: text.substring(start, match.start)));
|
spans.add(TextSpan(text: text.substring(start, match.start)));
|
||||||
}
|
}
|
||||||
spans.add(TextSpan(
|
spans.add(TextSpan(
|
||||||
text: match.group(0) ?? '',
|
text: match.group(0) ?? '',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
@@ -1141,13 +1158,9 @@ Widget createDialogContent(String text) {
|
|||||||
spans.add(TextSpan(text: text.substring(start)));
|
spans.add(TextSpan(text: text.substring(start)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasLink) {
|
|
||||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
|
||||||
}
|
|
||||||
|
|
||||||
return SelectableText.rich(
|
return SelectableText.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
style: TextStyle(color: Colors.black, fontSize: 15),
|
style: const TextStyle(fontSize: 15),
|
||||||
children: spans,
|
children: spans,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1566,7 +1579,7 @@ bool option2bool(String option, String value) {
|
|||||||
option == kOptionForceAlwaysRelay) {
|
option == kOptionForceAlwaysRelay) {
|
||||||
res = value == "Y";
|
res = value == "Y";
|
||||||
} else {
|
} else {
|
||||||
assert(false);
|
// "" is true
|
||||||
res = value != "N";
|
res = value != "N";
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -1584,9 +1597,6 @@ String bool2option(String option, bool b) {
|
|||||||
option == kOptionForceAlwaysRelay) {
|
option == kOptionForceAlwaysRelay) {
|
||||||
res = b ? 'Y' : defaultOptionNo;
|
res = b ? 'Y' : defaultOptionNo;
|
||||||
} else {
|
} else {
|
||||||
if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
|
|
||||||
assert(false);
|
|
||||||
}
|
|
||||||
res = b ? 'Y' : 'N';
|
res = b ? 'Y' : 'N';
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -1622,7 +1632,8 @@ bool mainGetPeerBoolOptionSync(String id, String key) {
|
|||||||
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
||||||
// Because all session options use `Y` and `<Empty>` as values.
|
// Because all session options use `Y` and `<Empty>` as values.
|
||||||
|
|
||||||
Future<bool> matchPeer(String searchText, Peer peer) async {
|
Future<bool> matchPeer(
|
||||||
|
String searchText, Peer peer, PeerTabIndex peerTabIndex) async {
|
||||||
if (searchText.isEmpty) {
|
if (searchText.isEmpty) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1633,11 +1644,14 @@ Future<bool> matchPeer(String searchText, Peer peer) async {
|
|||||||
peer.username.toLowerCase().contains(searchText)) {
|
peer.username.toLowerCase().contains(searchText)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final alias = peer.alias;
|
if (peer.alias.toLowerCase().contains(searchText)) {
|
||||||
if (alias.isEmpty) {
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return alias.toLowerCase().contains(searchText);
|
if (peerTabShowNote(peerTabIndex) &&
|
||||||
|
peer.note.toLowerCase().contains(searchText)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the image for the current [platform].
|
/// Get the image for the current [platform].
|
||||||
@@ -1667,6 +1681,15 @@ class LastWindowPosition {
|
|||||||
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
||||||
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
||||||
|
|
||||||
|
bool equals(LastWindowPosition other) {
|
||||||
|
return ((width == other.width) &&
|
||||||
|
(height == other.height) &&
|
||||||
|
(offsetWidth == other.offsetWidth) &&
|
||||||
|
(offsetHeight == other.offsetHeight) &&
|
||||||
|
(isMaximized == other.isMaximized) &&
|
||||||
|
(isFullscreen == other.isFullscreen));
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
"width": width,
|
"width": width,
|
||||||
@@ -1706,24 +1729,36 @@ String get windowFramePrefix =>
|
|||||||
? "incoming_"
|
? "incoming_"
|
||||||
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
||||||
|
|
||||||
|
typedef WindowKey = ({WindowType type, int? windowId});
|
||||||
|
|
||||||
|
LastWindowPosition? _lastWindowPosition = null;
|
||||||
|
final Debouncer _saveWindowDebounce = Debouncer(delay: Duration(seconds: 1));
|
||||||
|
|
||||||
/// Save window position and size on exit
|
/// Save window position and size on exit
|
||||||
/// Note that windowId must be provided if it's subwindow
|
/// Note that windowId must be provided if it's subwindow
|
||||||
Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
Future<void> saveWindowPosition(WindowType type,
|
||||||
|
{int? windowId, bool? flush}) async {
|
||||||
if (type != WindowType.Main && windowId == null) {
|
if (type != WindowType.Main && windowId == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Error: windowId cannot be null when saving positions for sub window");
|
"Error: windowId cannot be null when saving positions for sub window");
|
||||||
}
|
}
|
||||||
|
|
||||||
late Offset position;
|
Offset? position;
|
||||||
late Size sz;
|
Size? sz;
|
||||||
late bool isMaximized;
|
late bool isMaximized;
|
||||||
bool isFullscreen = stateGlobal.fullscreen.isTrue;
|
bool isFullscreen = stateGlobal.fullscreen.isTrue;
|
||||||
|
|
||||||
setPreFrame() {
|
setPreFrame() {
|
||||||
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
||||||
var lpos = LastWindowPosition.loadFromString(pos);
|
var lpos = LastWindowPosition.loadFromString(pos);
|
||||||
position = Offset(
|
if (lpos != null) {
|
||||||
lpos?.offsetWidth ?? position.dx, lpos?.offsetHeight ?? position.dy);
|
if (lpos.offsetWidth != null && lpos.offsetHeight != null) {
|
||||||
sz = Size(lpos?.width ?? sz.width, lpos?.height ?? sz.height);
|
position = Offset(lpos.offsetWidth!, lpos.offsetHeight!);
|
||||||
|
}
|
||||||
|
if (lpos.width != null && lpos.height != null) {
|
||||||
|
sz = Size(lpos.width!, lpos.height!);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -1763,30 +1798,56 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isWindows) {
|
if (isWindows && position != null) {
|
||||||
const kMinOffset = -10000;
|
const kMinOffset = -10000;
|
||||||
const kMaxOffset = 10000;
|
const kMaxOffset = 10000;
|
||||||
if (position.dx < kMinOffset ||
|
if (position!.dx < kMinOffset ||
|
||||||
position.dy < kMinOffset ||
|
position!.dy < kMinOffset ||
|
||||||
position.dx > kMaxOffset ||
|
position!.dx > kMaxOffset ||
|
||||||
position.dy > kMaxOffset) {
|
position!.dy > kMaxOffset) {
|
||||||
debugPrint("Invalid position: $position, ignore saving position");
|
debugPrint("Invalid position: $position, ignore saving position");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final pos = LastWindowPosition(
|
final pos = LastWindowPosition(sz?.width, sz?.height, position?.dx,
|
||||||
sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen);
|
position?.dy, isMaximized, isFullscreen);
|
||||||
debugPrint(
|
|
||||||
"Saving frame: $windowId: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
|
||||||
|
|
||||||
await bind.setLocalFlutterOption(
|
final WindowKey key = (type: type, windowId: windowId);
|
||||||
k: windowFramePrefix + type.name, v: pos.toString());
|
|
||||||
|
|
||||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
final bool haveNewWindowPosition =
|
||||||
windowId != null) {
|
(_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
|
||||||
await _saveSessionWindowPosition(
|
final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning;
|
||||||
type, windowId, isMaximized, isFullscreen, pos);
|
|
||||||
|
if (haveNewWindowPosition || isPreviousNewWindowPositionPending) {
|
||||||
|
_lastWindowPosition = pos;
|
||||||
|
|
||||||
|
if (flush ?? false) {
|
||||||
|
// If a previous update is pending, replace it.
|
||||||
|
_saveWindowDebounce.cancel();
|
||||||
|
await _saveWindowPositionActual(key);
|
||||||
|
} else if (haveNewWindowPosition) {
|
||||||
|
_saveWindowDebounce.call(() => _saveWindowPositionActual(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveWindowPositionActual(WindowKey key) async {
|
||||||
|
LastWindowPosition? pos = _lastWindowPosition;
|
||||||
|
|
||||||
|
if (pos != null) {
|
||||||
|
debugPrint(
|
||||||
|
"Saving frame: ${key.windowId}: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
||||||
|
|
||||||
|
await bind.setLocalFlutterOption(
|
||||||
|
k: windowFramePrefix + key.type.name, v: pos.toString());
|
||||||
|
|
||||||
|
if ((key.type == WindowType.RemoteDesktop ||
|
||||||
|
key.type == WindowType.ViewCamera) &&
|
||||||
|
key.windowId != null) {
|
||||||
|
await _saveSessionWindowPosition(key.type, key.windowId!,
|
||||||
|
pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1852,6 +1913,8 @@ Future<Size> _adjustRestoreMainWindowSize(double? width, double? height) async {
|
|||||||
return Size(restoreWidth, restoreHeight);
|
return Size(restoreWidth, restoreHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consider using Rect.contains() instead,
|
||||||
|
// though the implementation is not exactly the same.
|
||||||
bool isPointInRect(Offset point, Rect rect) {
|
bool isPointInRect(Offset point, Rect rect) {
|
||||||
return point.dx >= rect.left &&
|
return point.dx >= rect.left &&
|
||||||
point.dx <= rect.right &&
|
point.dx <= rect.right &&
|
||||||
@@ -1870,44 +1933,41 @@ Future<Offset?> _adjustRestoreMainWindowOffset(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
double? frameLeft;
|
|
||||||
double? frameTop;
|
|
||||||
double? frameRight;
|
|
||||||
double? frameBottom;
|
|
||||||
|
|
||||||
if (isDesktop || isWebDesktop) {
|
if (isDesktop || isWebDesktop) {
|
||||||
for (final screen in await window_size.getScreenList()) {
|
final screens = await window_size.getScreenList();
|
||||||
frameLeft = frameLeft == null
|
if (screens.isNotEmpty) {
|
||||||
? screen.visibleFrame.left
|
final windowRect = Rect.fromLTWH(left, top, width, height);
|
||||||
: min(screen.visibleFrame.left, frameLeft);
|
bool isVisible = false;
|
||||||
frameTop = frameTop == null
|
for (final screen in screens) {
|
||||||
? screen.visibleFrame.top
|
final intersection = windowRect.intersect(screen.visibleFrame);
|
||||||
: min(screen.visibleFrame.top, frameTop);
|
if (intersection.width >= 10.0 && intersection.height >= 10.0) {
|
||||||
frameRight = frameRight == null
|
isVisible = true;
|
||||||
? screen.visibleFrame.right
|
break;
|
||||||
: max(screen.visibleFrame.right, frameRight);
|
}
|
||||||
frameBottom = frameBottom == null
|
}
|
||||||
? screen.visibleFrame.bottom
|
if (!isVisible) {
|
||||||
: max(screen.visibleFrame.bottom, frameBottom);
|
return null;
|
||||||
|
}
|
||||||
|
return Offset(left, top);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (frameLeft == null) {
|
|
||||||
frameLeft = 0.0;
|
double frameLeft = 0.0;
|
||||||
frameTop = 0.0;
|
double frameTop = 0.0;
|
||||||
frameRight = ((isDesktop || isWebDesktop)
|
double frameRight = ((isDesktop || isWebDesktop)
|
||||||
? kDesktopMaxDisplaySize
|
? kDesktopMaxDisplaySize
|
||||||
: kMobileMaxDisplaySize)
|
: kMobileMaxDisplaySize)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
frameBottom = ((isDesktop || isWebDesktop)
|
double frameBottom = ((isDesktop || isWebDesktop)
|
||||||
? kDesktopMaxDisplaySize
|
? kDesktopMaxDisplaySize
|
||||||
: kMobileMaxDisplaySize)
|
: kMobileMaxDisplaySize)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
}
|
|
||||||
final minWidth = 10.0;
|
final minWidth = 10.0;
|
||||||
if ((left + minWidth) > frameRight! ||
|
if ((left + minWidth) > frameRight ||
|
||||||
(top + minWidth) > frameBottom! ||
|
(top + minWidth) > frameBottom ||
|
||||||
(left + width - minWidth) < frameLeft ||
|
(left + width - minWidth) < frameLeft ||
|
||||||
top < frameTop!) {
|
top < frameTop) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return Offset(left, top);
|
return Offset(left, top);
|
||||||
@@ -1949,8 +2009,24 @@ Future<bool> restoreWindowPosition(WindowType type,
|
|||||||
|
|
||||||
var lpos = LastWindowPosition.loadFromString(pos);
|
var lpos = LastWindowPosition.loadFromString(pos);
|
||||||
if (lpos == null) {
|
if (lpos == null) {
|
||||||
debugPrint("no window position saved, ignoring position restoration");
|
debugPrint("No window position saved, trying to center the window.");
|
||||||
return false;
|
switch (type) {
|
||||||
|
case WindowType.Main:
|
||||||
|
// Center the main window only if no position is saved (on first run).
|
||||||
|
if (isWindows || isLinux) {
|
||||||
|
await windowManager.center();
|
||||||
|
}
|
||||||
|
// For MacOS, the window is already centered by default.
|
||||||
|
// See https://github.com/rustdesk/rustdesk/blob/9b9276e7524523d7f667fefcd0694d981443df0e/flutter/macos/Runner/Base.lproj/MainMenu.xib#L333
|
||||||
|
// If `<windowPositionMask>` in `<window>` is not set, the window will be centered.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No need to change the position of a sub window if no position is saved,
|
||||||
|
// since the default position is already centered.
|
||||||
|
// https://github.com/rustdesk/rustdesk/blob/317639169359936f7f9f85ef445ec9774218772d/flutter/lib/utils/multi_window_manager.dart#L163
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||||
if (!isRemotePeerPos && windowId != null) {
|
if (!isRemotePeerPos && windowId != null) {
|
||||||
@@ -2598,6 +2674,55 @@ class SimpleWrapper<T> {
|
|||||||
SimpleWrapper(this.value);
|
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.
|
/// call this to reload current window.
|
||||||
///
|
///
|
||||||
/// [Note]
|
/// [Note]
|
||||||
@@ -2871,7 +2996,7 @@ Future<void> updateSystemWindowTheme() async {
|
|||||||
///
|
///
|
||||||
/// Note: not found a general solution for rust based AVFoundation bingding.
|
/// Note: not found a general solution for rust based AVFoundation bingding.
|
||||||
/// [AVFoundation] crate has compile error.
|
/// [AVFoundation] crate has compile error.
|
||||||
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos");
|
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/host");
|
||||||
|
|
||||||
enum PermissionAuthorizeType {
|
enum PermissionAuthorizeType {
|
||||||
undetermined,
|
undetermined,
|
||||||
@@ -2938,10 +3063,26 @@ Future<void> start_service(bool is_start) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> canBeBlocked() 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,
|
var option = option2bool(kOptionAllowRemoteConfigModification,
|
||||||
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
||||||
return access_mode == 'view' || (access_mode.isEmpty && !option);
|
return accessMode == 'view' || (isCustomAccessMode && !option);
|
||||||
}
|
}
|
||||||
|
|
||||||
// to-do: web not implemented
|
// to-do: web not implemented
|
||||||
@@ -3704,6 +3845,16 @@ setResizable(bool resizable) {
|
|||||||
|
|
||||||
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
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? _isCustomClient;
|
||||||
bool get isCustomClient {
|
bool get isCustomClient {
|
||||||
_isCustomClient ??= bind.isCustomClient();
|
_isCustomClient ??= bind.isCustomClient();
|
||||||
@@ -3943,3 +4094,67 @@ String decode_http_response(http.Response resp) {
|
|||||||
return resp.body;
|
return resp.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?
|
// Is all the fields of the user needed?
|
||||||
class UserPayload {
|
class UserPayload {
|
||||||
String name = '';
|
String name = '';
|
||||||
|
String displayName = '';
|
||||||
|
String avatar = '';
|
||||||
String email = '';
|
String email = '';
|
||||||
String note = '';
|
String note = '';
|
||||||
String? verifier;
|
String? verifier;
|
||||||
@@ -33,6 +35,8 @@ class UserPayload {
|
|||||||
|
|
||||||
UserPayload.fromJson(Map<String, dynamic> json)
|
UserPayload.fromJson(Map<String, dynamic> json)
|
||||||
: name = json['name'] ?? '',
|
: name = json['name'] ?? '',
|
||||||
|
displayName = json['display_name'] ?? '',
|
||||||
|
avatar = json['avatar'] ?? '',
|
||||||
email = json['email'] ?? '',
|
email = json['email'] ?? '',
|
||||||
note = json['note'] ?? '',
|
note = json['note'] ?? '',
|
||||||
verifier = json['verifier'],
|
verifier = json['verifier'],
|
||||||
@@ -46,6 +50,8 @@ class UserPayload {
|
|||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final Map<String, dynamic> map = {
|
final Map<String, dynamic> map = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'display_name': displayName,
|
||||||
|
'avatar': avatar,
|
||||||
'status': status == UserStatus.kDisabled
|
'status': status == UserStatus.kDisabled
|
||||||
? 0
|
? 0
|
||||||
: status == UserStatus.kUnverified
|
: status == UserStatus.kUnverified
|
||||||
@@ -58,9 +64,14 @@ class UserPayload {
|
|||||||
Map<String, dynamic> toGroupCacheJson() {
|
Map<String, dynamic> toGroupCacheJson() {
|
||||||
final Map<String, dynamic> map = {
|
final Map<String, dynamic> map = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'display_name': displayName,
|
||||||
};
|
};
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get displayNameOrName {
|
||||||
|
return displayName.trim().isEmpty ? name : displayName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PeerPayload {
|
class PeerPayload {
|
||||||
@@ -89,6 +100,7 @@ class PeerPayload {
|
|||||||
"platform": _platform(p.info['os']),
|
"platform": _platform(p.info['os']),
|
||||||
"hostname": p.info['device_name'],
|
"hostname": p.info['device_name'],
|
||||||
"device_group_name": p.device_group_name,
|
"device_group_name": p.device_group_name,
|
||||||
|
"note": p.note,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
IDTextEditingController idController = IDTextEditingController(text: '');
|
IDTextEditingController idController = IDTextEditingController(text: '');
|
||||||
TextEditingController aliasController = TextEditingController(text: '');
|
TextEditingController aliasController = TextEditingController(text: '');
|
||||||
TextEditingController passwordController = TextEditingController(text: '');
|
TextEditingController passwordController = TextEditingController(text: '');
|
||||||
|
TextEditingController noteController = TextEditingController(text: '');
|
||||||
final tags = List.of(gFFI.abModel.currentAbTags);
|
final tags = List.of(gFFI.abModel.currentAbTags);
|
||||||
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
||||||
final style = TextStyle(fontSize: 14.0);
|
final style = TextStyle(fontSize: 14.0);
|
||||||
@@ -494,7 +495,11 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
password = passwordController.text;
|
password = passwordController.text;
|
||||||
}
|
}
|
||||||
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
||||||
id, aliasController.text.trim(), password, selectedTag);
|
id,
|
||||||
|
aliasController.text.trim(),
|
||||||
|
password,
|
||||||
|
selectedTag,
|
||||||
|
noteController.text);
|
||||||
if (errMsg2 != null) {
|
if (errMsg2 != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isInProgress = false;
|
isInProgress = false;
|
||||||
@@ -600,6 +605,24 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
),
|
),
|
||||||
).workaroundFreezeLinuxMint(),
|
).workaroundFreezeLinuxMint(),
|
||||||
)),
|
)),
|
||||||
|
row(
|
||||||
|
label: Text(
|
||||||
|
translate('Note'),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
input: Obx(
|
||||||
|
() => TextField(
|
||||||
|
controller: noteController,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
maxLength: 300,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: stateGlobal.isPortrait.isFalse
|
||||||
|
? null
|
||||||
|
: translate('Note'),
|
||||||
|
),
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
)),
|
||||||
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
|||||||
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/scale.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
|
||||||
|
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
|
||||||
|
/// Implementations must provide [ffi] and [onScaleChanged] getters.
|
||||||
|
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
|
||||||
|
/// FFI instance for session interaction
|
||||||
|
FFI get ffi;
|
||||||
|
|
||||||
|
/// Callback invoked when scale value changes
|
||||||
|
ValueChanged<int>? get onScaleChanged;
|
||||||
|
|
||||||
|
late int _scaleValue;
|
||||||
|
late final Debouncer<int> _debouncerScale;
|
||||||
|
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||||
|
double _scalePos = 0.0;
|
||||||
|
|
||||||
|
int get scaleValue => _scaleValue;
|
||||||
|
double get scalePos => _scalePos;
|
||||||
|
|
||||||
|
int mapPosToPercent(double p) => _mapPosToPercent(p);
|
||||||
|
|
||||||
|
static const int minPercent = kScaleCustomMinPercent;
|
||||||
|
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||||
|
static const int maxPercent = kScaleCustomMaxPercent;
|
||||||
|
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||||
|
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||||
|
|
||||||
|
// Clamp helper for local use
|
||||||
|
int _clampScale(int v) => clampCustomScalePercent(v);
|
||||||
|
|
||||||
|
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||||
|
int _mapPosToPercent(double p) {
|
||||||
|
if (p <= 0.0) return minPercent;
|
||||||
|
if (p >= 1.0) return maxPercent;
|
||||||
|
if (p <= pivotPos) {
|
||||||
|
final q = p / pivotPos; // 0..1
|
||||||
|
final v = minPercent + q * (pivotPercent - minPercent);
|
||||||
|
return _clampScale(v.round());
|
||||||
|
} else {
|
||||||
|
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
|
||||||
|
final v = pivotPercent + q * (maxPercent - pivotPercent);
|
||||||
|
return _clampScale(v.round());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map percent [5,1000] → normalized position [0,1]
|
||||||
|
double _mapPercentToPos(int percent) {
|
||||||
|
final p = _clampScale(percent);
|
||||||
|
if (p <= pivotPercent) {
|
||||||
|
final q = (p - minPercent) / (pivotPercent - minPercent);
|
||||||
|
return q * pivotPos;
|
||||||
|
} else {
|
||||||
|
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
|
||||||
|
return pivotPos + q * (1.0 - pivotPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap normalized position to the pivot when close to it
|
||||||
|
double _snapNormalizedPos(double p) {
|
||||||
|
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
|
||||||
|
if (p < 0.0) return 0.0;
|
||||||
|
if (p > 1.0) return 1.0;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scaleValue = 100;
|
||||||
|
_debouncerScale = Debouncer<int>(
|
||||||
|
kDebounceCustomScaleDuration,
|
||||||
|
onChanged: (v) async {
|
||||||
|
await _applyScale(v);
|
||||||
|
},
|
||||||
|
initialValue: _scaleValue,
|
||||||
|
);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
try {
|
||||||
|
final v = await getSessionCustomScalePercent(ffi.sessionId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = v;
|
||||||
|
_scalePos = _mapPercentToPos(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyScale(int v) async {
|
||||||
|
v = clampCustomScalePercent(v);
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = v;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await bind.sessionSetFlutterOption(
|
||||||
|
sessionId: ffi.sessionId,
|
||||||
|
k: kCustomScalePercentKey,
|
||||||
|
v: v.toString());
|
||||||
|
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||||
|
if (curStyle != kRemoteViewStyleCustom) {
|
||||||
|
await bind.sessionSetViewStyle(
|
||||||
|
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||||
|
}
|
||||||
|
await ffi.canvasModel.updateViewStyle();
|
||||||
|
if (isMobile) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
}
|
||||||
|
onScaleChanged?.call(v);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[CustomScale] Apply failed: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void nudgeScale(int delta) {
|
||||||
|
final next = _clampScale(_scaleValue + delta);
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = next;
|
||||||
|
_scalePos = _mapPercentToPos(next);
|
||||||
|
});
|
||||||
|
onScaleChanged?.call(next);
|
||||||
|
_debouncerScale.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debouncerScale.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSliderChanged(double v) {
|
||||||
|
final snapped = _snapNormalizedPos(v);
|
||||||
|
final next = _mapPosToPercent(snapped);
|
||||||
|
if (next != _scaleValue || snapped != _scalePos) {
|
||||||
|
setState(() {
|
||||||
|
_scalePos = snapped;
|
||||||
|
_scaleValue = next;
|
||||||
|
});
|
||||||
|
onScaleChanged?.call(next);
|
||||||
|
_debouncerScale.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,29 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
import 'package:flutter_hbb/models/peer_model.dart';
|
import 'package:flutter_hbb/models/peer_model.dart';
|
||||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import 'address_book.dart';
|
import 'address_book.dart';
|
||||||
|
|
||||||
void clientClose(SessionID sessionId, OverlayDialogManager dialogManager) {
|
void clientClose(SessionID sessionId, FFI ffi) async {
|
||||||
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
if (allowAskForNoteAtEndOfConnection(ffi, true)) {
|
||||||
'', dialogManager);
|
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeConnection();
|
||||||
|
} else {
|
||||||
|
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
||||||
|
'', ffi.dialogManager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class ValidationRule {
|
abstract class ValidationRule {
|
||||||
@@ -1509,56 +1518,71 @@ showSetOSAccount(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildNoteTextField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required VoidCallback onEscape,
|
||||||
|
}) {
|
||||||
|
final focusNode = FocusNode(
|
||||||
|
onKey: (FocusNode node, RawKeyEvent evt) {
|
||||||
|
if (evt.logicalKey.keyLabel == 'Enter') {
|
||||||
|
if (evt is RawKeyDownEvent) {
|
||||||
|
int pos = controller.selection.base.offset;
|
||||||
|
controller.text =
|
||||||
|
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
|
||||||
|
controller.selection =
|
||||||
|
TextSelection.fromPosition(TextPosition(offset: pos + 1));
|
||||||
|
}
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (evt.logicalKey.keyLabel == 'Esc') {
|
||||||
|
if (evt is RawKeyDownEvent) {
|
||||||
|
onEscape();
|
||||||
|
}
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
} else {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: translate('input note here'),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.all(12),
|
||||||
|
),
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: null,
|
||||||
|
maxLength: 256,
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
).workaroundFreezeLinuxMint();
|
||||||
|
}
|
||||||
|
|
||||||
showAuditDialog(FFI ffi) async {
|
showAuditDialog(FFI ffi) async {
|
||||||
final controller = TextEditingController(text: ffi.auditNote);
|
final controller = TextEditingController(
|
||||||
|
text: bind.sessionGetLastAuditNote(sessionId: ffi.sessionId));
|
||||||
ffi.dialogManager.show((setState, close, context) {
|
ffi.dialogManager.show((setState, close, context) {
|
||||||
submit() {
|
submit() {
|
||||||
var text = controller.text;
|
var text = controller.text;
|
||||||
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
|
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
|
||||||
ffi.auditNote = text;
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
late final focusNode = FocusNode(
|
|
||||||
onKey: (FocusNode node, RawKeyEvent evt) {
|
|
||||||
if (evt.logicalKey.keyLabel == 'Enter') {
|
|
||||||
if (evt is RawKeyDownEvent) {
|
|
||||||
int pos = controller.selection.base.offset;
|
|
||||||
controller.text =
|
|
||||||
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
|
|
||||||
controller.selection =
|
|
||||||
TextSelection.fromPosition(TextPosition(offset: pos + 1));
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (evt.logicalKey.keyLabel == 'Esc') {
|
|
||||||
if (evt is RawKeyDownEvent) {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
} else {
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return CustomAlertDialog(
|
return CustomAlertDialog(
|
||||||
title: Text(translate('Note')),
|
title: Text(translate('Note')),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 250,
|
width: 250,
|
||||||
height: 120,
|
height: 120,
|
||||||
child: TextField(
|
child: buildNoteTextField(
|
||||||
autofocus: true,
|
|
||||||
keyboardType: TextInputType.multiline,
|
|
||||||
textInputAction: TextInputAction.newline,
|
|
||||||
decoration: const InputDecoration.collapsed(
|
|
||||||
hintText: 'input note here',
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
maxLength: 256,
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: focusNode,
|
onEscape: close,
|
||||||
).workaroundFreezeLinuxMint()),
|
)),
|
||||||
actions: [
|
actions: [
|
||||||
dialogButton('Cancel', onPressed: close, isOutline: true),
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
||||||
dialogButton('OK', onPressed: submit)
|
dialogButton('OK', onPressed: submit)
|
||||||
@@ -1569,6 +1593,223 @@ showAuditDialog(FFI ffi) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool allowAskForNoteAtEndOfConnection(FFI? ffi, bool closedByControlling) {
|
||||||
|
if (ffi == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection) &&
|
||||||
|
bind
|
||||||
|
.sessionGetAuditServerSync(sessionId: ffi.sessionId, typ: "conn")
|
||||||
|
.isNotEmpty &&
|
||||||
|
bind.sessionGetAuditGuid(sessionId: ffi.sessionId).isNotEmpty &&
|
||||||
|
bind.sessionGetLastAuditNote(sessionId: ffi.sessionId).isEmpty &&
|
||||||
|
(!closedByControlling ||
|
||||||
|
bind.willSessionCloseCloseSession(sessionId: ffi.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value: close canceled
|
||||||
|
// true: return
|
||||||
|
// false: go on
|
||||||
|
Future<bool> desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
{required String id, required DesktopTabController tabController}) async {
|
||||||
|
try {
|
||||||
|
final page =
|
||||||
|
tabController.state.value.tabs.firstWhere((tab) => tab.key == id).page;
|
||||||
|
final ffi = (page as dynamic).ffi;
|
||||||
|
final res = await showConnEndAuditDialogCloseCanceled(ffi: ffi);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to show audit dialog: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value:
|
||||||
|
// true: return
|
||||||
|
// false: go on
|
||||||
|
Future<bool> showConnEndAuditDialogCloseCanceled(
|
||||||
|
{required FFI ffi, String? type, String? title, String? text}) async {
|
||||||
|
final res = await _showConnEndAuditDialogCloseCanceled(
|
||||||
|
ffi: ffi, type: type, title: title, text: text);
|
||||||
|
if (res == true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value:
|
||||||
|
// true: return
|
||||||
|
// false / null: go on
|
||||||
|
Future<bool?> _showConnEndAuditDialogCloseCanceled({
|
||||||
|
required FFI ffi,
|
||||||
|
String? type,
|
||||||
|
String? title,
|
||||||
|
String? text,
|
||||||
|
}) async {
|
||||||
|
final closedByControlling = type == null;
|
||||||
|
final showDialog = allowAskForNoteAtEndOfConnection(ffi, closedByControlling);
|
||||||
|
if (!showDialog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ffi.dialogManager.dismissAll();
|
||||||
|
|
||||||
|
Future<void> updateAuditNoteByGuid(String auditGuid, String note) async {
|
||||||
|
debugPrint('Updating audit note for GUID: $auditGuid, note: $note');
|
||||||
|
try {
|
||||||
|
final apiServer = await bind.mainGetApiServer();
|
||||||
|
if (apiServer.isEmpty) {
|
||||||
|
debugPrint('API server is empty, cannot update audit note');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final url = '$apiServer/api/audit';
|
||||||
|
var headers = getHttpHeaders();
|
||||||
|
headers['Content-Type'] = "application/json";
|
||||||
|
final body = jsonEncode({
|
||||||
|
'guid': auditGuid,
|
||||||
|
'note': note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
debugPrint('Successfully updated audit note for GUID: $auditGuid');
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to update audit note. Status: ${response.statusCode}, Body: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error updating audit note: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = TextEditingController();
|
||||||
|
bool askForNote =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||||
|
final isOptFixed = isOptionFixed(kOptionAllowAskForNoteAtEndOfConnection);
|
||||||
|
bool isInProgress = false;
|
||||||
|
|
||||||
|
return await ffi.dialogManager.show<bool>((setState, close, context) {
|
||||||
|
cancel() {
|
||||||
|
close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
set() async {
|
||||||
|
if (isInProgress) return;
|
||||||
|
setState(() {
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
var text = controller.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
await updateAuditNoteByGuid(
|
||||||
|
bind.sessionGetAuditGuid(sessionId: ffi.sessionId), text)
|
||||||
|
.timeout(const Duration(seconds: 6), onTimeout: () {
|
||||||
|
debugPrint('updateAuditNoteByGuid timeout after 6s');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Save the "ask for note" preference
|
||||||
|
if (!isOptFixed) {
|
||||||
|
await mainSetLocalBoolOption(
|
||||||
|
kOptionAllowAskForNoteAtEndOfConnection, askForNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() async {
|
||||||
|
await set();
|
||||||
|
close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final buttons = [
|
||||||
|
dialogButton('OK', onPressed: isInProgress ? null : submit)
|
||||||
|
];
|
||||||
|
if (type == 'relay-hint' || type == 'relay-hint2') {
|
||||||
|
buttons.add(dialogButton('Retry', onPressed: () async {
|
||||||
|
await set();
|
||||||
|
close(true);
|
||||||
|
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, false);
|
||||||
|
}));
|
||||||
|
if (type == 'relay-hint2') {
|
||||||
|
buttons.add(dialogButton('Connect via relay', onPressed: () async {
|
||||||
|
await set();
|
||||||
|
close(true);
|
||||||
|
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closedByControlling) {
|
||||||
|
buttons.add(dialogButton('Cancel',
|
||||||
|
onPressed: isInProgress ? null : cancel, isOutline: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget content;
|
||||||
|
if (closedByControlling) {
|
||||||
|
content = SelectionArea(
|
||||||
|
child: msgboxContent(
|
||||||
|
'info', 'Close', 'Are you sure to close the connection?'));
|
||||||
|
} else {
|
||||||
|
content =
|
||||||
|
SelectionArea(child: msgboxContent(type, title ?? '', text ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: null,
|
||||||
|
content: SizedBox(
|
||||||
|
width: 350,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
content,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: buildNoteTextField(
|
||||||
|
controller: controller,
|
||||||
|
onEscape: cancel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isOptFixed) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
askForNote = !askForNote;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: askForNote,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
askForNote = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
translate('note-at-conn-end-tip'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isInProgress)
|
||||||
|
const LinearProgressIndicator().marginOnly(top: 4),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
actions: buttons,
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: cancel,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void showConfirmSwitchSidesDialog(
|
void showConfirmSwitchSidesDialog(
|
||||||
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
|
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
|
||||||
dialogManager.show((setState, close, context) {
|
dialogManager.show((setState, close, context) {
|
||||||
@@ -1783,6 +2024,49 @@ void editAbTagDialog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void editAbPeerNoteDialog(String id) {
|
||||||
|
var isInProgress = false;
|
||||||
|
final currentNote = gFFI.abModel.getPeerNote(id);
|
||||||
|
var controller = TextEditingController(text: currentNote);
|
||||||
|
|
||||||
|
gFFI.dialogManager.show((setState, close, context) {
|
||||||
|
submit() async {
|
||||||
|
setState(() {
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
await gFFI.abModel.changeNote(id: id, note: controller.text);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Edit note")),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
maxLength: 300,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: translate('Note'),
|
||||||
|
),
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
// NOT use Offstage to wrap LinearProgressIndicator
|
||||||
|
if (isInProgress) const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||||
|
dialogButton("OK", onPressed: submit),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void renameDialog(
|
void renameDialog(
|
||||||
{required String oldName,
|
{required String oldName,
|
||||||
FormFieldValidator<String>? validator,
|
FormFieldValidator<String>? validator,
|
||||||
@@ -2078,15 +2362,20 @@ void showWindowsSessionsDialog(
|
|||||||
|
|
||||||
return CustomAlertDialog(
|
return CustomAlertDialog(
|
||||||
title: null,
|
title: null,
|
||||||
content: msgboxContent(type, title, text),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
msgboxContent(type, title, text).marginOnly(bottom: 12),
|
||||||
|
ComboBox(
|
||||||
|
keys: sids,
|
||||||
|
values: names,
|
||||||
|
initialKey: selectedUserValue,
|
||||||
|
onChanged: (value) {
|
||||||
|
selectedUserValue = value;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
ComboBox(
|
|
||||||
keys: sids,
|
|
||||||
values: names,
|
|
||||||
initialKey: selectedUserValue,
|
|
||||||
onChanged: (value) {
|
|
||||||
selectedUserValue = value;
|
|
||||||
}),
|
|
||||||
dialogButton('Connect', onPressed: submit, isOutline: false),
|
dialogButton('Connect', onPressed: submit, isOutline: false),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||||
|
|
||||||
enum GestureState {
|
enum GestureState {
|
||||||
none,
|
none,
|
||||||
@@ -24,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
GestureDragStartCallback? onOneFingerPanStart;
|
GestureDragStartCallback? onOneFingerPanStart;
|
||||||
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
||||||
GestureDragEndCallback? onOneFingerPanEnd;
|
GestureDragEndCallback? onOneFingerPanEnd;
|
||||||
|
GestureDragCancelCallback? onOneFingerPanCancel;
|
||||||
|
|
||||||
// twoFingerScale : scale + pan event
|
// twoFingerScale : scale + pan event
|
||||||
GestureScaleStartCallback? onTwoFingerScaleStart;
|
GestureScaleStartCallback? onTwoFingerScaleStart;
|
||||||
@@ -96,6 +98,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
if (onTwoFingerScaleEnd != null) {
|
if (onTwoFingerScaleEnd != null) {
|
||||||
onTwoFingerScaleEnd!(d);
|
onTwoFingerScaleEnd!(d);
|
||||||
}
|
}
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// If we are in special drag mode, we need to reset the state.
|
||||||
|
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
|
||||||
|
_currentState = GestureState.none;
|
||||||
|
return;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case GestureState.threeFingerVerticalDrag:
|
case GestureState.threeFingerVerticalDrag:
|
||||||
debugPrint("ThreeFingerState.vertical onEnd");
|
debugPrint("ThreeFingerState.vertical onEnd");
|
||||||
@@ -162,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
|
|
||||||
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
||||||
DragEndDetails(velocity: d.velocity);
|
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 {
|
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
|
||||||
@@ -710,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({
|
|||||||
GestureDragStartCallback? onOneFingerPanStart,
|
GestureDragStartCallback? onOneFingerPanStart,
|
||||||
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
||||||
GestureDragEndCallback? onOneFingerPanEnd,
|
GestureDragEndCallback? onOneFingerPanEnd,
|
||||||
|
GestureDragCancelCallback? onOneFingerPanCancel,
|
||||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
||||||
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
||||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
||||||
@@ -758,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({
|
|||||||
..onOneFingerPanStart = onOneFingerPanStart
|
..onOneFingerPanStart = onOneFingerPanStart
|
||||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||||
|
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class ButtonOP extends StatelessWidget {
|
|||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text('${translate("Continue with")} $opLabel')),
|
child: Text(translate("Continue with {$opLabel}"))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -400,6 +400,8 @@ Future<bool?> loginDialog() async {
|
|||||||
String? passwordMsg;
|
String? passwordMsg;
|
||||||
var isInProgress = false;
|
var isInProgress = false;
|
||||||
final RxString curOP = ''.obs;
|
final RxString curOP = ''.obs;
|
||||||
|
// Track hover state for the close icon
|
||||||
|
bool isCloseHovered = false;
|
||||||
|
|
||||||
final loginOptions = [].obs;
|
final loginOptions = [].obs;
|
||||||
Future.delayed(Duration.zero, () async {
|
Future.delayed(Duration.zero, () async {
|
||||||
@@ -557,21 +559,27 @@ Future<bool?> loginDialog() async {
|
|||||||
Text(
|
Text(
|
||||||
translate('Login'),
|
translate('Login'),
|
||||||
).marginOnly(top: MyTheme.dialogPadding),
|
).marginOnly(top: MyTheme.dialogPadding),
|
||||||
InkWell(
|
MouseRegion(
|
||||||
child: Icon(
|
onEnter: (_) => setState(() => isCloseHovered = true),
|
||||||
Icons.close,
|
onExit: (_) => setState(() => isCloseHovered = false),
|
||||||
size: 25,
|
child: InkWell(
|
||||||
// No need to handle the branch of null.
|
child: Icon(
|
||||||
// Because we can ensure the color is not null when debug.
|
Icons.close,
|
||||||
color: Theme.of(context)
|
size: 25,
|
||||||
.textTheme
|
// No need to handle the branch of null.
|
||||||
.titleLarge
|
// Because we can ensure the color is not null when debug.
|
||||||
?.color
|
color: isCloseHovered
|
||||||
?.withOpacity(0.55),
|
? Colors.white
|
||||||
|
: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.color
|
||||||
|
?.withOpacity(0.55),
|
||||||
|
),
|
||||||
|
onTap: onDialogCancel,
|
||||||
|
hoverColor: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
onTap: onDialogCancel,
|
|
||||||
hoverColor: Colors.red,
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
).marginOnly(top: 10, right: 15),
|
).marginOnly(top: 10, right: 15),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,12 +158,18 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
return Obx(() {
|
return Obx(() {
|
||||||
final userItems = gFFI.groupModel.users.where((p0) {
|
final userItems = gFFI.groupModel.users.where((p0) {
|
||||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||||
return p0.name
|
final search = searchAccessibleItemNameText.value.toLowerCase();
|
||||||
.toLowerCase()
|
return p0.name.toLowerCase().contains(search) ||
|
||||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
p0.displayNameOrName.toLowerCase().contains(search);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).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) {
|
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
||||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||||
return p0.name
|
return p0.name
|
||||||
@@ -177,7 +183,8 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
itemCount: deviceGroupItems.length + userItems.length,
|
itemCount: deviceGroupItems.length + userItems.length,
|
||||||
itemBuilder: (context, index) => index < deviceGroupItems.length
|
itemBuilder: (context, index) => index < deviceGroupItems.length
|
||||||
? _buildDeviceGroupItem(deviceGroupItems[index])
|
? _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);
|
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||||
? listView(false)
|
? 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 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: () {
|
return InkWell(onTap: () {
|
||||||
isSelectedDeviceGroup.value = false;
|
isSelectedDeviceGroup.value = false;
|
||||||
if (selectedAccessibleItemName.value != username) {
|
if (selectedAccessibleItemName.value != username) {
|
||||||
@@ -222,14 +235,14 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
username.characters.first.toUpperCase(),
|
displayName.characters.first.toUpperCase(),
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).marginOnly(right: 4),
|
).marginOnly(right: 4),
|
||||||
if (isMe) Flexible(child: Text(username)),
|
if (isMe) Flexible(child: Text(displayName)),
|
||||||
if (isMe)
|
if (isMe)
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
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),
|
).paddingSymmetric(vertical: 4),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class DraggableChatWindow extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: Draggable(
|
: Draggable(
|
||||||
checkKeyboard: true,
|
checkKeyboard: true,
|
||||||
|
checkScreenSize: true,
|
||||||
position: draggablePositions.chatWindow,
|
position: draggablePositions.chatWindow,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -395,7 +396,10 @@ class _DraggableState extends State<Draggable> {
|
|||||||
_chatModel?.setChatWindowPosition(position);
|
_chatModel?.setChatWindowPosition(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkScreenSize() {}
|
checkScreenSize() {
|
||||||
|
// Ensure the draggable always stays within current screen bounds
|
||||||
|
widget.position.tryAdjust(widget.width, widget.height, 1);
|
||||||
|
}
|
||||||
|
|
||||||
checkKeyboard() {
|
checkKeyboard() {
|
||||||
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||||
@@ -517,6 +521,12 @@ class IOSDraggableState extends State<IOSDraggable> {
|
|||||||
_lastBottomHeight = bottomHeight;
|
_lastBottomHeight = bottomHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
position.tryAdjust(_width, _height, 1);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
checkKeyboard();
|
checkKeyboard();
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _showNote(Peer peer) {
|
||||||
|
return peerTabShowNote(widget.tab) && peer.note.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
makeChild(bool isPortrait, Peer peer) {
|
makeChild(bool isPortrait, Peer peer) {
|
||||||
final name = hideUsernameOnCard == true
|
final name = hideUsernameOnCard == true
|
||||||
? peer.hostname
|
? peer.hostname
|
||||||
@@ -134,6 +138,8 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
final greyStyle = TextStyle(
|
final greyStyle = TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
||||||
|
final showNote = _showNote(peer);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
@@ -185,14 +191,44 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
)),
|
)),
|
||||||
]).marginOnly(top: isPortrait ? 0 : 2),
|
]).marginOnly(top: isPortrait ? 0 : 2),
|
||||||
Align(
|
Row(
|
||||||
alignment: Alignment.centerLeft,
|
children: [
|
||||||
child: Text(
|
Flexible(
|
||||||
name,
|
child: Tooltip(
|
||||||
style: isPortrait ? null : greyStyle,
|
message: name,
|
||||||
textAlign: TextAlign.start,
|
waitDuration: const Duration(seconds: 1),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Align(
|
||||||
),
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: isPortrait ? null : greyStyle,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showNote)
|
||||||
|
Expanded(
|
||||||
|
child: Tooltip(
|
||||||
|
message: peer.note,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
peer.note,
|
||||||
|
style: isPortrait ? null : greyStyle,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
).marginOnly(
|
||||||
|
left: peerCardUiType.value ==
|
||||||
|
PeerUiType.list
|
||||||
|
? 32
|
||||||
|
: 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).marginOnly(top: 2),
|
).marginOnly(top: 2),
|
||||||
@@ -278,7 +314,7 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child:
|
child:
|
||||||
getPlatformImage(peer.platform, size: 60),
|
getPlatformImage(peer.platform, size: 60),
|
||||||
).marginOnly(top: 4),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -297,8 +333,26 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_showNote(peer))
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Tooltip(
|
||||||
|
message: peer.note,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: Text(
|
||||||
|
peer.note,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 10),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).paddingAll(4.0),
|
).paddingOnly(top: 4.0, left: 4.0, right: 4.0),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1134,6 +1188,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
|||||||
if (gFFI.abModel.currentAbTags.isNotEmpty) {
|
if (gFFI.abModel.currentAbTags.isNotEmpty) {
|
||||||
menuItems.add(_editTagAction(peer.id));
|
menuItems.add(_editTagAction(peer.id));
|
||||||
}
|
}
|
||||||
|
menuItems.add(_editNoteAction(peer.id));
|
||||||
}
|
}
|
||||||
final addressbooks = gFFI.abModel.addressBooksCanWrite();
|
final addressbooks = gFFI.abModel.addressBooksCanWrite();
|
||||||
if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) {
|
if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) {
|
||||||
@@ -1173,6 +1228,21 @@ class AddressBookPeerCard extends BasePeerCard {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
MenuEntryBase<String> _editNoteAction(String id) {
|
||||||
|
return MenuEntryButton<String>(
|
||||||
|
childBuilder: (TextStyle? style) => Text(
|
||||||
|
translate('Edit note'),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
proc: () {
|
||||||
|
editAbPeerNoteDialog(id);
|
||||||
|
},
|
||||||
|
padding: super.menuPadding,
|
||||||
|
dismissOnClicked: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
@override
|
@override
|
||||||
Future<String> _getAlias(String id) async =>
|
Future<String> _getAlias(String id) async =>
|
||||||
|
|||||||
@@ -71,10 +71,12 @@ class _PeersView extends StatefulWidget {
|
|||||||
final Peers peers;
|
final Peers peers;
|
||||||
final PeerFilter? peerFilter;
|
final PeerFilter? peerFilter;
|
||||||
final PeerCardBuilder peerCardBuilder;
|
final PeerCardBuilder peerCardBuilder;
|
||||||
|
final PeerTabIndex peerTabIndex;
|
||||||
|
|
||||||
const _PeersView(
|
const _PeersView(
|
||||||
{required this.peers,
|
{required this.peers,
|
||||||
required this.peerCardBuilder,
|
required this.peerCardBuilder,
|
||||||
|
required this.peerTabIndex,
|
||||||
this.peerFilter,
|
this.peerFilter,
|
||||||
Key? key})
|
Key? key})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
@@ -395,8 +397,8 @@ class _PeersViewState extends State<_PeersView>
|
|||||||
return peers;
|
return peers;
|
||||||
}
|
}
|
||||||
searchText = searchText.toLowerCase();
|
searchText = searchText.toLowerCase();
|
||||||
final matches =
|
final matches = await Future.wait(
|
||||||
await Future.wait(peers.map((peer) => matchPeer(searchText, peer)));
|
peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex)));
|
||||||
final filteredList = List<Peer>.empty(growable: true);
|
final filteredList = List<Peer>.empty(growable: true);
|
||||||
for (var i = 0; i < peers.length; i++) {
|
for (var i = 0; i < peers.length; i++) {
|
||||||
if (matches[i]) {
|
if (matches[i]) {
|
||||||
@@ -441,7 +443,10 @@ abstract class BasePeersView extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return _PeersView(
|
return _PeersView(
|
||||||
peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder);
|
peers: peers,
|
||||||
|
peerFilter: peerFilter,
|
||||||
|
peerCardBuilder: peerCardBuilder,
|
||||||
|
peerTabIndex: peerTabIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,11 +570,14 @@ class MyGroupPeerView extends BasePeersView {
|
|||||||
static bool filter(Peer peer) {
|
static bool filter(Peer peer) {
|
||||||
final model = gFFI.groupModel;
|
final model = gFFI.groupModel;
|
||||||
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
||||||
final text = model.searchAccessibleItemNameText.value;
|
final text = model.searchAccessibleItemNameText.value.toLowerCase();
|
||||||
final searchPeersOfUser = peer.loginName.contains(text) &&
|
final searchPeersOfUser = model.users.any((user) =>
|
||||||
model.users.any((user) => user.name == peer.loginName);
|
user.name == peer.loginName &&
|
||||||
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
|
(user.name.toLowerCase().contains(text) ||
|
||||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
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) {
|
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class RawKeyFocusScope extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For virtual mouse when using the mouse mode on mobile.
|
||||||
|
// Special hold-drag mode: one finger holds a button (left/right button), another finger pans.
|
||||||
|
// This flag is to override the scale gesture to a pan gesture.
|
||||||
|
bool isSpecialHoldDragActive = false;
|
||||||
|
// Cache the last focal point to calculate deltas in special hold-drag mode.
|
||||||
|
Offset _lastSpecialHoldDragFocalPoint = Offset.zero;
|
||||||
|
|
||||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final FFI ffi;
|
final FFI ffi;
|
||||||
@@ -97,6 +104,12 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
bool _touchModePanStarted = false;
|
bool _touchModePanStarted = false;
|
||||||
Offset _doubleFinerTapPosition = Offset.zero;
|
Offset _doubleFinerTapPosition = Offset.zero;
|
||||||
|
|
||||||
|
// 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;
|
FFI get ffi => widget.ffi;
|
||||||
FfiModel get ffiModel => widget.ffiModel;
|
FfiModel get ffiModel => widget.ffiModel;
|
||||||
InputModel get inputModel => widget.inputModel;
|
InputModel get inputModel => widget.inputModel;
|
||||||
@@ -115,8 +128,17 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile, mouse mode.
|
||||||
|
// Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`).
|
||||||
|
bool shouldBlockMouseModeEvent() {
|
||||||
|
return _lastTapDownPositionForMouseMode != null &&
|
||||||
|
ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx,
|
||||||
|
_lastTapDownPositionForMouseMode!.dy);
|
||||||
|
}
|
||||||
|
|
||||||
onTapDown(TapDownDetails d) async {
|
onTapDown(TapDownDetails d) async {
|
||||||
lastDeviceKind = d.kind;
|
lastDeviceKind = d.kind;
|
||||||
|
_lastTapDownGlobalPosition = d.globalPosition;
|
||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -124,6 +146,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
_lastPosOfDoubleTapDown = d.localPosition;
|
_lastPosOfDoubleTapDown = d.localPosition;
|
||||||
// Desktop or mobile "Touch mode"
|
// Desktop or mobile "Touch mode"
|
||||||
_lastTapDownDetails = d;
|
_lastTapDownDetails = d;
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +157,16 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||||
|
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (handleTouch) {
|
if (handleTouch) {
|
||||||
final isMoved =
|
final isMoved =
|
||||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
if (isMoved) {
|
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.tapDown(MouseButtons.left);
|
||||||
}
|
}
|
||||||
await inputModel.tapUp(MouseButtons.left);
|
await inputModel.tapUp(MouseButtons.left);
|
||||||
@@ -149,7 +178,17 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||||
|
final lastPos = _lastTapDownGlobalPosition;
|
||||||
|
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||||
|
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Mobile, "Mouse mode"
|
// Mobile, "Mouse mode"
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
}
|
}
|
||||||
@@ -163,6 +202,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (handleTouch) {
|
if (handleTouch) {
|
||||||
_lastPosOfDoubleTapDown = d.localPosition;
|
_lastPosOfDoubleTapDown = d.localPosition;
|
||||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +218,12 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Check if the position is in a blocked area when using the mouse mode.
|
||||||
|
if (!handleTouch) {
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
}
|
}
|
||||||
@@ -198,6 +245,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||||
await inputModel.tapDown(MouseButtons.left);
|
await inputModel.tapDown(MouseButtons.left);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +271,10 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (!isMoved) {
|
if (!isMoved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await inputModel.tap(MouseButtons.right);
|
await inputModel.tap(MouseButtons.right);
|
||||||
} else {
|
} else {
|
||||||
@@ -274,6 +327,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
if (isSpecialHoldDragActive) return;
|
||||||
await inputModel.sendMouse('down', MouseButtons.left);
|
await inputModel.sendMouse('down', MouseButtons.left);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,6 +337,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
if (isSpecialHoldDragActive) return;
|
||||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +385,10 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
await ffi.cursorModel
|
await ffi.cursorModel
|
||||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
.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);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
} else {
|
} else {
|
||||||
final offset = ffi.cursorModel.offset;
|
final offset = ffi.cursorModel.offset;
|
||||||
@@ -355,7 +413,12 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (handleTouch && !_touchModePanStarted) {
|
if (handleTouch && !_touchModePanStarted) {
|
||||||
return;
|
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 {
|
onOneFingerPanEnd(DragEndDetails d) async {
|
||||||
@@ -367,22 +430,47 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
ffi.cursorModel.clearRemoteWindowCoords();
|
ffi.cursorModel.clearRemoteWindowCoords();
|
||||||
}
|
}
|
||||||
if (handleTouch) {
|
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
|
// scale + pan event
|
||||||
onTwoFingerScaleStart(ScaleStartDetails d) {
|
onTwoFingerScaleStart(ScaleStartDetails d) {
|
||||||
_lastTapDownDetails = null;
|
_lastTapDownDetails = null;
|
||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// Initialize the last focal point to calculate deltas manually.
|
||||||
|
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If in special drag mode, perform a pan instead of a scale.
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// Calculate delta manually to avoid the jumpy behavior.
|
||||||
|
final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint;
|
||||||
|
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||||
|
await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ((isDesktop || isWebDesktop)) {
|
if ((isDesktop || isWebDesktop)) {
|
||||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||||
_scale = d.scale;
|
_scale = d.scale;
|
||||||
@@ -420,7 +508,9 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
// No idea why we need to set the view style to "" here.
|
// No idea why we need to set the view style to "" here.
|
||||||
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
||||||
}
|
}
|
||||||
await inputModel.sendMouse('up', MouseButtons.left);
|
if (!isSpecialHoldDragActive) {
|
||||||
|
await inputModel.sendMouse('up', MouseButtons.left);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get onHoldDragCancel => null;
|
get onHoldDragCancel => null;
|
||||||
@@ -488,6 +578,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
instance
|
instance
|
||||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||||
|
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||||
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
||||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ List<(String, String)> otherDefaultSettings() {
|
|||||||
('Disable clipboard', kOptionDisableClipboard),
|
('Disable clipboard', kOptionDisableClipboard),
|
||||||
('Lock after session end', kOptionLockAfterSessionEnd),
|
('Lock after session end', kOptionLockAfterSessionEnd),
|
||||||
('Privacy mode', kOptionPrivacyMode),
|
('Privacy mode', kOptionPrivacyMode),
|
||||||
if (isMobile) ('Touch mode', kOptionTouchMode),
|
|
||||||
('True color (4:4:4)', kOptionI444),
|
('True color (4:4:4)', kOptionI444),
|
||||||
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
||||||
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/dialog.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/consts.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/models/model.dart';
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
bool isEditOsPassword = false;
|
bool isEditOsPassword = false;
|
||||||
@@ -193,14 +195,26 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note
|
// note
|
||||||
if (isDefaultConn &&
|
if (isDefaultConn && !bind.isDisableAccount()) {
|
||||||
bind
|
|
||||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
|
||||||
.isNotEmpty) {
|
|
||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('Note')),
|
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
|
// divider
|
||||||
@@ -363,6 +377,11 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
|||||||
child: Text(translate('Scale adaptive')),
|
child: Text(translate('Scale adaptive')),
|
||||||
value: kRemoteViewStyleAdaptive,
|
value: kRemoteViewStyleAdaptive,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
|
onChanged: onChanged),
|
||||||
|
TRadioMenu<String>(
|
||||||
|
child: Text(translate('Scale custom')),
|
||||||
|
value: kRemoteViewStyleCustom,
|
||||||
|
groupValue: groupValue,
|
||||||
onChanged: onChanged)
|
onChanged: onChanged)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -812,6 +831,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
|
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||||
List<TToggleMenu> v = [];
|
List<TToggleMenu> v = [];
|
||||||
|
|
||||||
// swap key
|
// swap key
|
||||||
@@ -833,6 +853,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
child: Text(translate('Swap control-command key'))));
|
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
|
// reverse mouse wheel
|
||||||
if (ffiModel.keyboard) {
|
if (ffiModel.keyboard) {
|
||||||
var optionValue =
|
var optionValue =
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const String kAppTypeDesktopPortForward = "port forward";
|
|||||||
const String kAppTypeDesktopTerminal = "terminal";
|
const String kAppTypeDesktopTerminal = "terminal";
|
||||||
|
|
||||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||||
|
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||||
const String kWindowGetWindowInfo = "get_window_info";
|
const String kWindowGetWindowInfo = "get_window_info";
|
||||||
const String kWindowGetScreenList = "get_screen_list";
|
const String kWindowGetScreenList = "get_screen_list";
|
||||||
// This method is not used, maybe it can be removed.
|
// This method is not used, maybe it can be removed.
|
||||||
@@ -58,6 +59,7 @@ const String kWindowActionRebuild = "rebuild";
|
|||||||
const String kWindowEventHide = "hide";
|
const String kWindowEventHide = "hide";
|
||||||
const String kWindowEventShow = "show";
|
const String kWindowEventShow = "show";
|
||||||
const String kWindowConnect = "connect";
|
const String kWindowConnect = "connect";
|
||||||
|
const String kWindowBumpMouse = "bump_mouse";
|
||||||
|
|
||||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||||
@@ -78,6 +80,7 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session";
|
|||||||
|
|
||||||
const String kOptionViewStyle = "view_style";
|
const String kOptionViewStyle = "view_style";
|
||||||
const String kOptionScrollStyle = "scroll_style";
|
const String kOptionScrollStyle = "scroll_style";
|
||||||
|
const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
|
||||||
const String kOptionImageQuality = "image_quality";
|
const String kOptionImageQuality = "image_quality";
|
||||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||||
const String kOptionTextureRender = "use-texture-render";
|
const String kOptionTextureRender = "use-texture-render";
|
||||||
@@ -118,6 +121,7 @@ const String kOptionApproveMode = "approve-mode";
|
|||||||
const String kOptionAllowNumericOneTimePassword =
|
const String kOptionAllowNumericOneTimePassword =
|
||||||
"allow-numeric-one-time-password";
|
"allow-numeric-one-time-password";
|
||||||
const String kOptionCollapseToolbar = "collapse_toolbar";
|
const String kOptionCollapseToolbar = "collapse_toolbar";
|
||||||
|
const String kOptionHideToolbar = "hide-toolbar";
|
||||||
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
||||||
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
||||||
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
||||||
@@ -155,11 +159,19 @@ const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
|||||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||||
|
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
|
// network options
|
||||||
const String kOptionAllowWebSocket = "allow-websocket";
|
const String kOptionAllowWebSocket = "allow-websocket";
|
||||||
|
const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback";
|
||||||
|
const String kOptionDisableUdp = "disable-udp";
|
||||||
|
const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
|
||||||
|
|
||||||
// buildin opitons
|
// builtin options
|
||||||
const String kOptionHideServerSetting = "hide-server-settings";
|
const String kOptionHideServerSetting = "hide-server-settings";
|
||||||
const String kOptionHideProxySetting = "hide-proxy-settings";
|
const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||||
@@ -168,6 +180,10 @@ const String kOptionHideSecuritySetting = "hide-security-settings";
|
|||||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||||
const String kOptionRemovePresetPasswordWarning =
|
const String kOptionRemovePresetPasswordWarning =
|
||||||
"remove-preset-password-warning";
|
"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 kHideUsernameOnCard = "hide-username-on-card";
|
||||||
const String kOptionHideHelpCards = "hide-help-cards";
|
const String kOptionHideHelpCards = "hide-help-cards";
|
||||||
|
|
||||||
@@ -178,6 +194,9 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
|
|||||||
|
|
||||||
const String kOptionKeepScreenOn = "keep-screen-on";
|
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 kOptionShowMobileAction = "showMobileActions";
|
||||||
|
|
||||||
const String kUrlActionClose = "close";
|
const String kUrlActionClose = "close";
|
||||||
@@ -242,6 +261,33 @@ const int kMinTrackpadSpeed = 10;
|
|||||||
const int kDefaultTrackpadSpeed = 100;
|
const int kDefaultTrackpadSpeed = 100;
|
||||||
const int kMaxTrackpadSpeed = 1000;
|
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.
|
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
||||||
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
||||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||||
@@ -313,12 +359,18 @@ const kRemoteViewStyleOriginal = 'original';
|
|||||||
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
|
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
|
||||||
const kRemoteViewStyleAdaptive = 'adaptive';
|
const kRemoteViewStyleAdaptive = 'adaptive';
|
||||||
|
|
||||||
|
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
|
||||||
|
const kRemoteViewStyleCustom = 'custom';
|
||||||
|
|
||||||
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
||||||
const kRemoteScrollStyleAuto = 'scrollauto';
|
const kRemoteScrollStyleAuto = 'scrollauto';
|
||||||
|
|
||||||
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
|
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
|
||||||
const kRemoteScrollStyleBar = 'scrollbar';
|
const kRemoteScrollStyleBar = 'scrollbar';
|
||||||
|
|
||||||
|
/// [kRemoteScrollStyleEdge] Scroll image auto at edges.
|
||||||
|
const kRemoteScrollStyleEdge = 'scrolledge';
|
||||||
|
|
||||||
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
|
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
|
||||||
const kScrollModeDefault = 'default';
|
const kScrollModeDefault = 'default';
|
||||||
|
|
||||||
@@ -345,6 +397,17 @@ const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
|
|||||||
PointerDeviceKind.invertedStylus,
|
PointerDeviceKind.invertedStylus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scale custom related constants
|
||||||
|
const String kCustomScalePercentKey =
|
||||||
|
'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
|
||||||
|
const int kScaleCustomMinPercent = 5;
|
||||||
|
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
|
||||||
|
const int kScaleCustomMaxPercent = 1000;
|
||||||
|
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
|
||||||
|
const double kScaleCustomDetentEpsilon =
|
||||||
|
0.006; // snap range around pivot (~0.6%)
|
||||||
|
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
|
||||||
|
|
||||||
// ================================ mobile ================================
|
// ================================ mobile ================================
|
||||||
|
|
||||||
// Magic numbers, maybe need to avoid it or use a better way to get them.
|
// Magic numbers, maybe need to avoid it or use a better way to get them.
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
rdpUsername: '',
|
rdpUsername: '',
|
||||||
loginName: '',
|
loginName: '',
|
||||||
device_group_name: '',
|
device_group_name: '',
|
||||||
|
note: '',
|
||||||
);
|
);
|
||||||
_autocompleteOpts = [emptyPeer];
|
_autocompleteOpts = [emptyPeer];
|
||||||
} else {
|
} else {
|
||||||
@@ -536,64 +537,68 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
var offset = Offset(0, 0);
|
var offset = Offset(0, 0);
|
||||||
return Obx(() => InkWell(
|
return Obx(() => InkWell(
|
||||||
child: _menuOpen.value
|
child: _menuOpen.value
|
||||||
? Transform.rotate(
|
? Transform.rotate(
|
||||||
angle: pi,
|
angle: pi,
|
||||||
child: Icon(IconFont.more, size: 14),
|
child: Icon(IconFont.more, size: 14),
|
||||||
|
)
|
||||||
|
: Icon(IconFont.more, size: 14),
|
||||||
|
onTapDown: (e) {
|
||||||
|
offset = e.globalPosition;
|
||||||
|
},
|
||||||
|
onTap: () async {
|
||||||
|
_menuOpen.value = true;
|
||||||
|
final x = offset.dx;
|
||||||
|
final y = offset.dy;
|
||||||
|
await mod_menu
|
||||||
|
.showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||||
|
items: [
|
||||||
|
(
|
||||||
|
'Transfer file',
|
||||||
|
() => onConnect(isFileTransfer: true)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'View camera',
|
||||||
|
() => onConnect(isViewCamera: true)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'${translate('Terminal')} (beta)',
|
||||||
|
() => onConnect(isTerminal: true)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((e) => MenuEntryButton<String>(
|
||||||
|
childBuilder: (TextStyle? style) =>
|
||||||
|
Text(
|
||||||
|
translate(e.$1),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
proc: () => e.$2(),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal:
|
||||||
|
kDesktopMenuPadding.left),
|
||||||
|
dismissOnClicked: true,
|
||||||
|
))
|
||||||
|
.map((e) => e.build(
|
||||||
|
context,
|
||||||
|
const MenuConfig(
|
||||||
|
commonColor: CustomPopupMenuTheme
|
||||||
|
.commonColor,
|
||||||
|
height:
|
||||||
|
CustomPopupMenuTheme.height,
|
||||||
|
dividerHeight:
|
||||||
|
CustomPopupMenuTheme
|
||||||
|
.dividerHeight)))
|
||||||
|
.expand((i) => i)
|
||||||
|
.toList(),
|
||||||
|
elevation: 8,
|
||||||
)
|
)
|
||||||
: Icon(IconFont.more, size: 14),
|
.then((_) {
|
||||||
onTapDown: (e) {
|
_menuOpen.value = false;
|
||||||
offset = e.globalPosition;
|
});
|
||||||
},
|
},
|
||||||
onTap: () async {
|
));
|
||||||
_menuOpen.value = true;
|
|
||||||
final x = offset.dx;
|
|
||||||
final y = offset.dy;
|
|
||||||
await mod_menu
|
|
||||||
.showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
|
||||||
items: [
|
|
||||||
(
|
|
||||||
'Transfer file',
|
|
||||||
() => onConnect(isFileTransfer: true)
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'View camera',
|
|
||||||
() => onConnect(isViewCamera: true)
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'${translate('Terminal')} (beta)',
|
|
||||||
() => onConnect(isTerminal: true)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.map((e) => MenuEntryButton<String>(
|
|
||||||
childBuilder: (TextStyle? style) => Text(
|
|
||||||
translate(e.$1),
|
|
||||||
style: style,
|
|
||||||
),
|
|
||||||
proc: () => e.$2(),
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: kDesktopMenuPadding.left),
|
|
||||||
dismissOnClicked: true,
|
|
||||||
))
|
|
||||||
.map((e) => e.build(
|
|
||||||
context,
|
|
||||||
const MenuConfig(
|
|
||||||
commonColor:
|
|
||||||
CustomPopupMenuTheme.commonColor,
|
|
||||||
height: CustomPopupMenuTheme.height,
|
|
||||||
dividerHeight: CustomPopupMenuTheme
|
|
||||||
.dividerHeight)))
|
|
||||||
.expand((i) => i)
|
|
||||||
.toList(),
|
|
||||||
elevation: 8,
|
|
||||||
)
|
|
||||||
.then((_) {
|
|
||||||
_menuOpen.value = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'package:flutter_hbb/models/server_model.dart';
|
|||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
|
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -449,7 +450,11 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
||||||
btnText,
|
btnText,
|
||||||
onPressed,
|
onPressed,
|
||||||
closeButton: true);
|
closeButton: true,
|
||||||
|
help: isToUpdate ? 'Changelog' : null,
|
||||||
|
link: isToUpdate
|
||||||
|
? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
|
||||||
|
: null);
|
||||||
}
|
}
|
||||||
if (systemError.isNotEmpty) {
|
if (systemError.isNotEmpty) {
|
||||||
return buildInstallCard("", systemError, "", () {});
|
return buildInstallCard("", systemError, "", () {});
|
||||||
@@ -760,11 +765,23 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
'scaleFactor': screen.scaleFactor,
|
'scaleFactor': screen.scaleFactor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool isChattyMethod(String methodName) {
|
||||||
|
switch (methodName) {
|
||||||
|
case kWindowBumpMouse: return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
|
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
|
||||||
debugPrint(
|
if (!isChattyMethod(call.method)) {
|
||||||
|
debugPrint(
|
||||||
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||||
|
}
|
||||||
if (call.method == kWindowMainWindowOnTop) {
|
if (call.method == kWindowMainWindowOnTop) {
|
||||||
windowOnTop(null);
|
windowOnTop(null);
|
||||||
|
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||||
|
gFFI.userModel.refreshCurrentUser();
|
||||||
} else if (call.method == kWindowGetWindowInfo) {
|
} else if (call.method == kWindowGetWindowInfo) {
|
||||||
final screen = (await window_size.getWindowInfo()).screen;
|
final screen = (await window_size.getWindowInfo()).screen;
|
||||||
if (screen == null) {
|
if (screen == null) {
|
||||||
@@ -793,6 +810,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
forceRelay: call.arguments['forceRelay'],
|
forceRelay: call.arguments['forceRelay'],
|
||||||
connToken: call.arguments['connToken'],
|
connToken: call.arguments['connToken'],
|
||||||
);
|
);
|
||||||
|
} else if (call.method == kWindowBumpMouse) {
|
||||||
|
return RdPlatformChannel.instance.bumpMouse(
|
||||||
|
dx: call.arguments['dx'],
|
||||||
|
dy: call.arguments['dy']);
|
||||||
} else if (call.method == kWindowEventMoveTabToNewWindow) {
|
} else if (call.method == kWindowEventMoveTabToNewWindow) {
|
||||||
final args = call.arguments.split(',');
|
final args = call.arguments.split(',');
|
||||||
int? windowId;
|
int? windowId;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
|||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/printer_model.dart';
|
import 'package:flutter_hbb/models/printer_model.dart';
|
||||||
@@ -472,8 +473,7 @@ class _GeneralState extends State<_General> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget other() {
|
Widget other() {
|
||||||
final showAutoUpdate =
|
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||||
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
|
|
||||||
final children = <Widget>[
|
final children = <Widget>[
|
||||||
if (!isWeb && !bind.isIncomingOnly())
|
if (!isWeb && !bind.isIncomingOnly())
|
||||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||||
@@ -556,10 +556,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)) {
|
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||||
children.add(_OptionCheckBox(
|
children.add(_OptionCheckBox(
|
||||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||||
}
|
}
|
||||||
|
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);
|
return _Card(title: 'Other', children: children);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,7 +835,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
permissions(context),
|
permissions(context),
|
||||||
password(context),
|
password(context),
|
||||||
_Card(title: '2FA', children: [tfa()]),
|
_Card(title: '2FA', children: [tfa()]),
|
||||||
_Card(title: 'ID', children: [changeId()]),
|
if (!isChangeIdDisabled())
|
||||||
|
_Card(title: 'ID', children: [changeId()]),
|
||||||
more(context),
|
more(context),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -1075,6 +1102,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
.indexOf(kUsePermanentPassword)] &&
|
.indexOf(kUsePermanentPassword)] &&
|
||||||
(await bind.mainGetPermanentPassword())
|
(await bind.mainGetPermanentPassword())
|
||||||
.isEmpty) {
|
.isEmpty) {
|
||||||
|
if (isChangePermanentPasswordDisabled()) {
|
||||||
|
await callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPasswordDialog(notEmptyCallback: callback);
|
setPasswordDialog(notEmptyCallback: callback);
|
||||||
} else {
|
} else {
|
||||||
await callback();
|
await callback();
|
||||||
@@ -1177,9 +1208,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
enabled: tmpEnabled && !locked),
|
enabled: tmpEnabled && !locked),
|
||||||
numericOneTimePassword,
|
if (usePassword) numericOneTimePassword,
|
||||||
if (usePassword) radios[1],
|
if (usePassword) radios[1],
|
||||||
if (usePassword)
|
if (usePassword && !isChangePermanentPasswordDisabled())
|
||||||
_SubButton('Set permanent password', setPasswordDialog,
|
_SubButton('Set permanent password', setPasswordDialog,
|
||||||
permEnabled && !locked),
|
permEnabled && !locked),
|
||||||
// if (usePassword)
|
// if (usePassword)
|
||||||
@@ -1198,11 +1229,14 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
...directIp(context),
|
...directIp(context),
|
||||||
whitelist(),
|
whitelist(),
|
||||||
...autoDisconnect(context),
|
...autoDisconnect(context),
|
||||||
|
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||||
|
kOptionKeepAwakeDuringIncomingSessions,
|
||||||
|
reverse: false, enabled: enabled),
|
||||||
if (bind.mainIsInstalled())
|
if (bind.mainIsInstalled())
|
||||||
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
||||||
'allow-only-conn-window-open',
|
'allow-only-conn-window-open',
|
||||||
reverse: false, enabled: enabled),
|
reverse: false, enabled: enabled),
|
||||||
if (bind.mainIsInstalled()) unlockPin()
|
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,6 +1619,27 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget switchWidget(IconData icon, String title, String tooltipMessage,
|
||||||
|
String optionKey) =>
|
||||||
|
listTile(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
showTooltip: true,
|
||||||
|
tooltipMessage: tooltipMessage,
|
||||||
|
trailing: Switch(
|
||||||
|
value: mainGetBoolOptionSync(optionKey),
|
||||||
|
onChanged: locked || isOptionFixed(optionKey)
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
mainSetBoolOption(optionKey, value);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final outgoingOnly = bind.isOutgoingOnly();
|
||||||
|
|
||||||
|
final divider = const Divider(height: 1, indent: 16, endIndent: 16);
|
||||||
return _Card(
|
return _Card(
|
||||||
title: 'Network',
|
title: 'Network',
|
||||||
children: [
|
children: [
|
||||||
@@ -1596,33 +1651,65 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
|||||||
listTile(
|
listTile(
|
||||||
icon: Icons.dns_outlined,
|
icon: Icons.dns_outlined,
|
||||||
title: 'ID/Relay Server',
|
title: 'ID/Relay Server',
|
||||||
onTap: () => showServerSettings(gFFI.dialogManager),
|
onTap: () => showServerSettings(gFFI.dialogManager, setState),
|
||||||
),
|
),
|
||||||
if (!hideServer && (!hideProxy || !hideWebSocket))
|
if (!hideProxy && !hideServer) divider,
|
||||||
Divider(height: 1, indent: 16, endIndent: 16),
|
|
||||||
if (!hideProxy)
|
if (!hideProxy)
|
||||||
listTile(
|
listTile(
|
||||||
icon: Icons.network_ping_outlined,
|
icon: Icons.network_ping_outlined,
|
||||||
title: 'Socks5/Http(s) Proxy',
|
title: 'Socks5/Http(s) Proxy',
|
||||||
onTap: changeSocks5Proxy,
|
onTap: changeSocks5Proxy,
|
||||||
),
|
),
|
||||||
if (!hideProxy && !hideWebSocket)
|
if (!hideWebSocket && (!hideServer || !hideProxy)) divider,
|
||||||
Divider(height: 1, indent: 16, endIndent: 16),
|
|
||||||
if (!hideWebSocket)
|
if (!hideWebSocket)
|
||||||
listTile(
|
switchWidget(
|
||||||
icon: Icons.web_asset_outlined,
|
Icons.web_asset_outlined,
|
||||||
title: 'Use WebSocket',
|
'Use WebSocket',
|
||||||
showTooltip: true,
|
'${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||||
tooltipMessage: 'websocket_tip',
|
kOptionAllowWebSocket),
|
||||||
trailing: Switch(
|
if (!isWeb)
|
||||||
value: mainGetBoolOptionSync(kOptionAllowWebSocket),
|
futureBuilder(
|
||||||
onChanged: locked
|
future: bind.mainIsUsingPublicServer(),
|
||||||
? null
|
hasData: (isUsingPublicServer) {
|
||||||
: (value) {
|
if (isUsingPublicServer) {
|
||||||
mainSetBoolOption(kOptionAllowWebSocket, value);
|
return Offstage();
|
||||||
setState(() {});
|
} else {
|
||||||
},
|
return Column(
|
||||||
),
|
children: [
|
||||||
|
if (!hideServer || !hideProxy || !hideWebSocket)
|
||||||
|
divider,
|
||||||
|
switchWidget(
|
||||||
|
Icons.no_encryption_outlined,
|
||||||
|
'Allow insecure TLS fallback',
|
||||||
|
'allow-insecure-tls-fallback-tip',
|
||||||
|
kOptionAllowInsecureTLSFallback),
|
||||||
|
if (!outgoingOnly) divider,
|
||||||
|
if (!outgoingOnly)
|
||||||
|
listTile(
|
||||||
|
icon: Icons.lan_outlined,
|
||||||
|
title: 'Disable UDP',
|
||||||
|
showTooltip: true,
|
||||||
|
tooltipMessage:
|
||||||
|
'${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||||
|
trailing: Switch(
|
||||||
|
value: bind.mainGetOptionSync(
|
||||||
|
key: kOptionDisableUdp) ==
|
||||||
|
'Y',
|
||||||
|
onChanged:
|
||||||
|
locked || isOptionFixed(kOptionDisableUdp)
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
await bind.mainSetOption(
|
||||||
|
key: kOptionDisableUdp,
|
||||||
|
value: value ? 'Y' : 'N');
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1685,6 +1772,13 @@ class _DisplayState extends State<_Display> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
|
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
|
||||||
|
|
||||||
|
onEdgeScrollEdgeThicknessChanged(double value) async {
|
||||||
|
await bind.mainSetUserDefaultOption(
|
||||||
|
key: kOptionEdgeScrollEdgeThickness, value: value.round().toString());
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
return _Card(title: 'Default Scroll Style', children: [
|
return _Card(title: 'Default Scroll Style', children: [
|
||||||
_Radio(context,
|
_Radio(context,
|
||||||
value: kRemoteScrollStyleAuto,
|
value: kRemoteScrollStyleAuto,
|
||||||
@@ -1696,6 +1790,23 @@ class _DisplayState extends State<_Display> {
|
|||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
label: 'Scrollbar',
|
label: 'Scrollbar',
|
||||||
onChanged: isOptFixed ? null : onChanged),
|
onChanged: isOptFixed ? null : onChanged),
|
||||||
|
if (!isWeb) ...[
|
||||||
|
_Radio(context,
|
||||||
|
value: kRemoteScrollStyleEdge,
|
||||||
|
groupValue: groupValue,
|
||||||
|
label: 'ScrollEdge',
|
||||||
|
onChanged: isOptFixed ? null : onChanged),
|
||||||
|
Offstage(
|
||||||
|
offstage: groupValue != kRemoteScrollStyleEdge,
|
||||||
|
child: EdgeThicknessControl(
|
||||||
|
value: double.tryParse(bind.mainGetUserDefaultOption(
|
||||||
|
key: kOptionEdgeScrollEdgeThickness)) ??
|
||||||
|
100.0,
|
||||||
|
onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness)
|
||||||
|
? null
|
||||||
|
: onEdgeScrollEdgeThicknessChanged,
|
||||||
|
)),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1737,9 +1848,9 @@ class _DisplayState extends State<_Display> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget trackpadSpeed(BuildContext context) {
|
Widget trackpadSpeed(BuildContext context) {
|
||||||
final initSpeed = (int.tryParse(
|
final initSpeed =
|
||||||
bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
(int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
||||||
kDefaultTrackpadSpeed);
|
kDefaultTrackpadSpeed);
|
||||||
final curSpeed = SimpleWrapper(initSpeed);
|
final curSpeed = SimpleWrapper(initSpeed);
|
||||||
void onDebouncer(int v) {
|
void onDebouncer(int v) {
|
||||||
bind.mainSetUserDefaultOption(
|
bind.mainSetUserDefaultOption(
|
||||||
@@ -1904,7 +2015,9 @@ class _AccountState extends State<_Account> {
|
|||||||
|
|
||||||
Widget accountAction() {
|
Widget accountAction() {
|
||||||
return Obx(() => _Button(
|
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
|
gFFI.userModel.userName.value.isEmpty
|
||||||
? loginDialog()
|
? loginDialog()
|
||||||
@@ -1913,24 +2026,65 @@ class _AccountState extends State<_Account> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget useInfo() {
|
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(
|
return Obx(() => Offstage(
|
||||||
offstage: gFFI.userModel.userName.value.isEmpty,
|
offstage: gFFI.userModel.userName.value.isEmpty,
|
||||||
child: Column(
|
child: Container(
|
||||||
children: [
|
padding: const EdgeInsets.all(12),
|
||||||
text('Username', gFFI.userModel.userName.value),
|
decoration: BoxDecoration(
|
||||||
// text('Group', gFFI.groupModel.groupName.value),
|
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);
|
)).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 {
|
class _Checkbox extends StatefulWidget {
|
||||||
@@ -2018,7 +2172,9 @@ class _PluginState extends State<_Plugin> {
|
|||||||
|
|
||||||
Widget accountAction() {
|
Widget accountAction() {
|
||||||
return Obx(() => _Button(
|
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
|
gFFI.userModel.userName.value.isEmpty
|
||||||
? loginDialog()
|
? loginDialog()
|
||||||
@@ -2426,6 +2582,49 @@ class WaylandCard extends StatefulWidget {
|
|||||||
|
|
||||||
class _WaylandCardState extends State<WaylandCard> {
|
class _WaylandCardState extends State<WaylandCard> {
|
||||||
final restoreTokenKey = 'wayland-restore-token';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2433,9 +2632,16 @@ class _WaylandCardState extends State<WaylandCard> {
|
|||||||
future: bind.mainHandleWaylandScreencastRestoreToken(
|
future: bind.mainHandleWaylandScreencastRestoreToken(
|
||||||
key: restoreTokenKey, value: "get"),
|
key: restoreTokenKey, value: "get"),
|
||||||
hasData: (restoreToken) {
|
hasData: (restoreToken) {
|
||||||
|
final hasShortcutsPermission = showResetInhibitorPermission &&
|
||||||
|
bind.mainGetCommonSync(
|
||||||
|
key: "has-gnome-shortcuts-inhibitor-permission") ==
|
||||||
|
"true";
|
||||||
|
|
||||||
final children = [
|
final children = [
|
||||||
if (restoreToken.isNotEmpty)
|
if (restoreToken.isNotEmpty)
|
||||||
_buildClearScreenSelection(context, restoreToken),
|
_buildClearScreenSelection(context, restoreToken),
|
||||||
|
if (hasShortcutsPermission)
|
||||||
|
_buildClearShortcutsInhibitorPermission(context),
|
||||||
];
|
];
|
||||||
return Offstage(
|
return Offstage(
|
||||||
offstage: children.isEmpty,
|
offstage: children.isEmpty,
|
||||||
@@ -2480,6 +2686,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
|
// ignore: non_constant_identifier_names
|
||||||
@@ -2561,7 +2811,7 @@ Widget _lock(
|
|||||||
]).marginSymmetric(vertical: 2)),
|
]).marginSymmetric(vertical: 2)),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final unlockPin = bind.mainGetUnlockPin();
|
final unlockPin = bind.mainGetUnlockPin();
|
||||||
if (unlockPin.isEmpty) {
|
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
|
||||||
bool checked = await callMainCheckSuperUserPermission();
|
bool checked = await callMainCheckSuperUserPermission();
|
||||||
if (checked) {
|
if (checked) {
|
||||||
onUnlock();
|
onUnlock();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:extended_text/extended_text.dart';
|
import 'package:extended_text/extended_text.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
|
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
|
||||||
import 'package:percent_indicator/percent_indicator.dart';
|
import 'package:percent_indicator/percent_indicator.dart';
|
||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
@@ -16,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
|||||||
import 'package:flutter_hbb/models/file_model.dart';
|
import 'package:flutter_hbb/models/file_model.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/web/dummy.dart'
|
import 'package:flutter_hbb/web/dummy.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ enum MouseFocusScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileManagerPage extends StatefulWidget {
|
class FileManagerPage extends StatefulWidget {
|
||||||
const FileManagerPage(
|
FileManagerPage(
|
||||||
{Key? key,
|
{Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
@@ -67,9 +67,16 @@ class FileManagerPage extends StatefulWidget {
|
|||||||
final bool? forceRelay;
|
final bool? forceRelay;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final DesktopTabController? tabController;
|
final DesktopTabController? tabController;
|
||||||
|
final SimpleWrapper<State<FileManagerPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _FileManagerPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _FileManagerPageState();
|
State<StatefulWidget> createState() {
|
||||||
|
final state = _FileManagerPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileManagerPageState extends State<FileManagerPage>
|
class _FileManagerPageState extends State<FileManagerPage>
|
||||||
@@ -78,6 +85,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
|
|
||||||
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
||||||
final _overlayKeyState = OverlayKeyState();
|
final _overlayKeyState = OverlayKeyState();
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
|
|
||||||
@@ -99,9 +107,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||||
}
|
}
|
||||||
@@ -119,9 +125,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
model.close().whenComplete(() {
|
model.close().whenComplete(() {
|
||||||
_ffi.close();
|
_ffi.close();
|
||||||
_ffi.dialogManager.dismissAll();
|
_ffi.dialogManager.dismissAll();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
@@ -139,12 +143,26 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget willPopScope(Widget child) {
|
||||||
|
if (isWeb) {
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
clientClose(_ffi.sessionId, _ffi);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
return Overlay(key: _overlayKeyState.key, initialEntries: [
|
return Overlay(key: _overlayKeyState.key, initialEntries: [
|
||||||
OverlayEntry(builder: (_) {
|
OverlayEntry(builder: (_) {
|
||||||
return Scaffold(
|
return willPopScope(Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -160,7 +178,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
Flexible(flex: 2, child: statusList())
|
Flexible(flex: 2, child: statusList())
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -260,11 +278,9 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
item.state != JobState.inProgress,
|
item.state != JobState.inProgress,
|
||||||
child: LinearPercentIndicator(
|
child: LinearPercentIndicator(
|
||||||
animateFromLastPercent: true,
|
animateFromLastPercent: true,
|
||||||
center: Text(
|
center: Text(item.percentText),
|
||||||
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
|
|
||||||
),
|
|
||||||
barRadius: Radius.circular(15),
|
barRadius: Radius.circular(15),
|
||||||
percent: item.finishedSize / item.totalSize,
|
percent: item.percent,
|
||||||
progressColor: MyTheme.accent,
|
progressColor: MyTheme.accent,
|
||||||
backgroundColor: Theme.of(context).hoverColor,
|
backgroundColor: Theme.of(context).hoverColor,
|
||||||
lineHeight: kDesktopFileTransferRowHeight,
|
lineHeight: kDesktopFileTransferRowHeight,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
|
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
|
||||||
@@ -40,7 +41,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
label: params['id'],
|
label: params['id'],
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(params['id']),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: params['id'],
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(params['id']);
|
||||||
|
},
|
||||||
page: FileManagerPage(
|
page: FileManagerPage(
|
||||||
key: ValueKey(params['id']),
|
key: ValueKey(params['id']),
|
||||||
id: params['id'],
|
id: params['id'],
|
||||||
@@ -69,7 +78,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: FileManagerPage(
|
page: FileManagerPage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
@@ -132,6 +149,14 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.state.value.tabs.length;
|
final connLength = tabController.state.value.tabs.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class _PortForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PortForwardPage extends StatefulWidget {
|
class PortForwardPage extends StatefulWidget {
|
||||||
const PortForwardPage({
|
PortForwardPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
@@ -42,9 +42,16 @@ class PortForwardPage extends StatefulWidget {
|
|||||||
final bool? forceRelay;
|
final bool? forceRelay;
|
||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
|
final SimpleWrapper<State<PortForwardPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PortForwardPage> createState() => _PortForwardPageState();
|
State<PortForwardPage> createState() {
|
||||||
|
final state = _PortForwardPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PortForwardPageState extends State<PortForwardPage>
|
class _PortForwardPageState extends State<PortForwardPage>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import 'dart:async';
|
|||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
|
|
||||||
import '../../consts.dart';
|
import '../../consts.dart';
|
||||||
@@ -15,6 +15,7 @@ import '../../common.dart';
|
|||||||
import '../../common/widgets/dialog.dart';
|
import '../../common/widgets/dialog.dart';
|
||||||
import '../../common/widgets/toolbar.dart';
|
import '../../common/widgets/toolbar.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
|
import '../../models/input_model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../common/shared_state.dart';
|
import '../../common/shared_state.dart';
|
||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
@@ -72,7 +73,10 @@ class RemotePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RemotePageState extends State<RemotePage>
|
class _RemotePageState extends State<RemotePage>
|
||||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
with
|
||||||
|
AutomaticKeepAliveClientMixin,
|
||||||
|
MultiWindowListener,
|
||||||
|
TickerProviderStateMixin {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
String keyboardMode = "legacy";
|
String keyboardMode = "legacy";
|
||||||
bool _isWindowBlur = false;
|
bool _isWindowBlur = false;
|
||||||
@@ -81,11 +85,16 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
late RxBool _zoomCursor;
|
late RxBool _zoomCursor;
|
||||||
late RxBool _remoteCursorMoved;
|
late RxBool _remoteCursorMoved;
|
||||||
late RxBool _keyboardEnabled;
|
late RxBool _keyboardEnabled;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
var _blockableOverlayState = BlockableOverlayState();
|
var _blockableOverlayState = BlockableOverlayState();
|
||||||
|
|
||||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
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`
|
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||||
// to identify the toolbar instance and its callback function.
|
// to identify the toolbar instance and its callback function.
|
||||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||||
@@ -112,11 +121,13 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi = FFI(widget.sessionId);
|
_ffi = FFI(widget.sessionId);
|
||||||
Get.put<FFI>(_ffi, tag: widget.id);
|
Get.put<FFI>(_ffi, tag: widget.id);
|
||||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||||
|
_ffi.canvasModel.activateLocalCursor();
|
||||||
showKBLayoutTypeChooserIfNeeded(
|
showKBLayoutTypeChooserIfNeeded(
|
||||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||||
_ffi.recordingModel
|
_ffi.recordingModel
|
||||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||||
});
|
});
|
||||||
|
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||||
_ffi.start(
|
_ffi.start(
|
||||||
widget.id,
|
widget.id,
|
||||||
password: widget.password,
|
password: widget.password,
|
||||||
@@ -132,9 +143,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.dialogManager
|
_ffi.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||||
@@ -165,6 +174,16 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
widget.tabController?.onSelected?.call(widget.id);
|
widget.tabController?.onSelected?.call(widget.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||||
|
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||||
|
_cancelPointerLockCenterDebounceTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the pointer lock center debounce timer
|
||||||
|
void _cancelPointerLockCenterDebounceTimer() {
|
||||||
|
_pointerLockCenterDebounceTimer?.cancel();
|
||||||
|
_pointerLockCenterDebounceTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -180,6 +199,13 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_rawKeyFocusNode.unfocus();
|
_rawKeyFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
stateGlobal.isFocused.value = false;
|
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
|
@override
|
||||||
@@ -190,6 +216,12 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
stateGlobal.isFocused.value = true;
|
stateGlobal.isFocused.value = true;
|
||||||
|
|
||||||
|
// Restore relative mouse mode constraints when window regains focus.
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
_rawKeyFocusNode.requestFocus();
|
||||||
|
_ffi.inputModel.onWindowFocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -200,25 +232,59 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
// 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.
|
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||||
@override
|
@override
|
||||||
void onWindowMaximize() {
|
void onWindowMaximize() {
|
||||||
super.onWindowMaximize();
|
super.onWindowMaximize();
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
// 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
|
@override
|
||||||
void onWindowMinimize() {
|
void onWindowMinimize() {
|
||||||
super.onWindowMinimize();
|
super.onWindowMinimize();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
// Release cursor constraints when minimized
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
_ffi.inputModel.onWindowBlur();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +311,16 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
// https://github.com/flutter/flutter/issues/64935
|
// https://github.com/flutter/flutter/issues/64935
|
||||||
super.dispose();
|
super.dispose();
|
||||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
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;
|
||||||
|
// 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);
|
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||||
if (closeSession) {
|
if (closeSession) {
|
||||||
// ensure we leave this session, this is a double check
|
// ensure we leave this session, this is a double check
|
||||||
@@ -262,9 +338,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
await Get.delete<FFI>(tag: widget.id);
|
await Get.delete<FFI>(tag: widget.id);
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
}
|
}
|
||||||
@@ -348,10 +422,15 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
// Use Overlay to enable rebuild every time on menu button click.
|
// Use Overlay to enable rebuild every time on menu button click.
|
||||||
_ffi.ffiModel.pi.isSet.isTrue
|
// Hide toolbar when relative mouse mode is active to prevent
|
||||||
? Overlay(
|
// cursor from escaping to toolbar area.
|
||||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
Obx(() => _ffi.inputModel.relativeMouseMode.value
|
||||||
: remoteToolbar(context),
|
? const Offstage()
|
||||||
|
: _ffi.ffiModel.pi.isSet.isTrue
|
||||||
|
? Overlay(initialEntries: [
|
||||||
|
OverlayEntry(builder: remoteToolbar)
|
||||||
|
])
|
||||||
|
: remoteToolbar(context)),
|
||||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -395,7 +474,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
super.build(context);
|
super.build(context);
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, _ffi.dialogManager);
|
clientClose(sessionId, _ffi);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: MultiProvider(providers: [
|
child: MultiProvider(providers: [
|
||||||
@@ -408,6 +487,8 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void enterView(PointerEnterEvent evt) {
|
void enterView(PointerEnterEvent evt) {
|
||||||
|
_ffi.canvasModel.rearmEdgeScroll();
|
||||||
|
|
||||||
_cursorOverImage.value = true;
|
_cursorOverImage.value = true;
|
||||||
_firstEnterImage.value = true;
|
_firstEnterImage.value = true;
|
||||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||||
@@ -417,6 +498,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See [onWindowBlur].
|
// See [onWindowBlur].
|
||||||
if (!isWindows) {
|
if (!isWindows) {
|
||||||
if (!_rawKeyFocusNode.hasFocus) {
|
if (!_rawKeyFocusNode.hasFocus) {
|
||||||
@@ -427,6 +509,8 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void leaveView(PointerExitEvent evt) {
|
void leaveView(PointerExitEvent evt) {
|
||||||
|
_ffi.canvasModel.disableEdgeScroll();
|
||||||
|
|
||||||
if (_ffi.ffiModel.keyboard) {
|
if (_ffi.ffiModel.keyboard) {
|
||||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||||
}
|
}
|
||||||
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See [onWindowBlur].
|
// See [onWindowBlur].
|
||||||
if (!isWindows) {
|
if (!isWindows) {
|
||||||
_ffi.inputModel.enterOrLeave(false);
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
@@ -487,33 +572,39 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
|
|
||||||
Widget getBodyForDesktop(BuildContext context) {
|
Widget getBodyForDesktop(BuildContext context) {
|
||||||
var paints = <Widget>[
|
var paints = <Widget>[
|
||||||
MouseRegion(onEnter: (evt) {
|
MouseRegion(
|
||||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
onEnter: (evt) {
|
||||||
}, onExit: (evt) {
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
},
|
||||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
onExit: (evt) {
|
||||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
},
|
||||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
child: _ViewStyleUpdater(
|
||||||
return Obx(
|
canvasModel: _ffi.canvasModel,
|
||||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
inputModel: _ffi.inputModel,
|
||||||
? Container(color: Colors.transparent)
|
child: Builder(builder: (context) {
|
||||||
: Obx(() {
|
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||||
widget.toolbarState.initShow(sessionId);
|
return Obx(
|
||||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||||
return ImagePaint(
|
? Container(color: Colors.transparent)
|
||||||
id: widget.id,
|
: Obx(() {
|
||||||
zoomCursor: _zoomCursor,
|
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||||
cursorOverImage: _cursorOverImage,
|
return ImagePaint(
|
||||||
keyboardEnabled: _keyboardEnabled,
|
id: widget.id,
|
||||||
remoteCursorMoved: _remoteCursorMoved,
|
zoomCursor: _zoomCursor,
|
||||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
cursorOverImage: _cursorOverImage,
|
||||||
child, enterView, leaveView),
|
keyboardEnabled: _keyboardEnabled,
|
||||||
ffi: _ffi,
|
remoteCursorMoved: _remoteCursorMoved,
|
||||||
);
|
listenerBuilder: (child) =>
|
||||||
}),
|
_buildRawTouchAndPointerRegion(
|
||||||
);
|
child, enterView, leaveView),
|
||||||
}))
|
ffi: _ffi,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!_ffi.canvasModel.cursorEmbedded) {
|
if (!_ffi.canvasModel.cursorEmbedded) {
|
||||||
@@ -542,6 +633,63 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
bool get wantKeepAlive => true;
|
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 {
|
class ImagePaint extends StatefulWidget {
|
||||||
final FFI ffi;
|
final FFI ffi;
|
||||||
final String id;
|
final String id;
|
||||||
@@ -606,26 +754,29 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
cursor: cursorOverImage.isTrue
|
cursor: cursorOverImage.isTrue
|
||||||
? c.cursorEmbedded
|
? c.cursorEmbedded
|
||||||
? SystemMouseCursors.none
|
? SystemMouseCursors.none
|
||||||
: keyboardEnabled.isTrue
|
// Hide cursor when relative mouse mode is active
|
||||||
? (() {
|
: widget.ffi.inputModel.relativeMouseMode.value
|
||||||
if (remoteCursorMoved.isTrue) {
|
? SystemMouseCursors.none
|
||||||
_lastRemoteCursorMoved = true;
|
: keyboardEnabled.isTrue
|
||||||
return SystemMouseCursors.none;
|
? (() {
|
||||||
} else {
|
if (remoteCursorMoved.isTrue) {
|
||||||
if (_lastRemoteCursorMoved) {
|
_lastRemoteCursorMoved = true;
|
||||||
_lastRemoteCursorMoved = false;
|
return SystemMouseCursors.none;
|
||||||
_firstEnterImage.value = true;
|
} else {
|
||||||
}
|
if (_lastRemoteCursorMoved) {
|
||||||
return _buildCustomCursor(
|
_lastRemoteCursorMoved = false;
|
||||||
context, getCursorScale());
|
_firstEnterImage.value = true;
|
||||||
}
|
}
|
||||||
}())
|
return _buildCustomCursor(
|
||||||
: _buildDisabledCursor(context, getCursorScale())
|
context, getCursorScale());
|
||||||
|
}
|
||||||
|
}())
|
||||||
|
: _buildDisabledCursor(context, getCursorScale())
|
||||||
: MouseCursor.defer,
|
: MouseCursor.defer,
|
||||||
onHover: (evt) {},
|
onHover: (evt) {},
|
||||||
child: child);
|
child: child);
|
||||||
});
|
});
|
||||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
final paintWidth = c.getDisplayWidth() * s;
|
final paintWidth = c.getDisplayWidth() * s;
|
||||||
final paintHeight = c.getDisplayHeight() * s;
|
final paintHeight = c.getDisplayHeight() * s;
|
||||||
final paintSize = Size(paintWidth, paintHeight);
|
final paintSize = Size(paintWidth, paintHeight);
|
||||||
@@ -680,9 +831,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
|
|
||||||
Widget _buildScrollAutoNonTextureRender(
|
Widget _buildScrollAutoNonTextureRender(
|
||||||
ImageModel m, CanvasModel c, double s) {
|
ImageModel m, CanvasModel c, double s) {
|
||||||
|
double sizeScale = s;
|
||||||
|
if (widget.ffi.ffiModel.isPeerLinux) {
|
||||||
|
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
|
||||||
|
if (displays.isNotEmpty) {
|
||||||
|
sizeScale = s / displays[0].scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
size: Size(c.size.width, c.size.height),
|
size: Size(c.size.width, c.size.height),
|
||||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
painter: ImagePainter(
|
||||||
|
image: m.image,
|
||||||
|
x: c.x / sizeScale,
|
||||||
|
y: c.y / sizeScale,
|
||||||
|
scale: sizeScale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,17 +857,19 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
if (rect == null) {
|
if (rect == null) {
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
|
final isPeerLinux = ffiModel.isPeerLinux;
|
||||||
final curDisplay = ffiModel.pi.currentDisplay;
|
final curDisplay = ffiModel.pi.currentDisplay;
|
||||||
for (var i = 0; i < displays.length; i++) {
|
for (var i = 0; i < displays.length; i++) {
|
||||||
final textureId = widget.ffi.textureModel
|
final textureId = widget.ffi.textureModel
|
||||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||||
if (true) {
|
if (true) {
|
||||||
// both "textureId.value != -1" and "true" seems ok
|
// both "textureId.value != -1" and "true" seems ok
|
||||||
|
final sizeScale = isPeerLinux ? s / displays[i].scale : s;
|
||||||
children.add(Positioned(
|
children.add(Positioned(
|
||||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||||
width: displays[i].width * s,
|
width: displays[i].width * sizeScale,
|
||||||
height: displays[i].height * s,
|
height: displays[i].height * sizeScale,
|
||||||
child: Obx(() => Texture(
|
child: Obx(() => Texture(
|
||||||
textureId: textureId.value,
|
textureId: textureId.value,
|
||||||
filterQuality:
|
filterQuality:
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
label: peerId!,
|
label: peerId!,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: peerId!,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(peerId!);
|
||||||
|
},
|
||||||
page: RemotePage(
|
page: RemotePage(
|
||||||
key: ValueKey(peerId),
|
key: ValueKey(peerId),
|
||||||
id: peerId!,
|
id: peerId!,
|
||||||
@@ -127,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
body: DesktopTab(
|
body: DesktopTab(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
onWindowCloseButton: handleWindowCloseButton,
|
onWindowCloseButton: handleWindowCloseButton,
|
||||||
tail: const AddButton(),
|
tail: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_RelativeMouseModeHint(tabController: tabController),
|
||||||
|
const AddButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
selectedBorderColor: MyTheme.accent,
|
selectedBorderColor: MyTheme.accent,
|
||||||
pageViewBuilder: (pageView) => pageView,
|
pageViewBuilder: (pageView) => pageView,
|
||||||
labelGetter: DesktopTab.tablabelGetter,
|
labelGetter: DesktopTab.tablabelGetter,
|
||||||
@@ -243,11 +257,11 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
MenuEntryButton<String>(
|
MenuEntryButton<String>(
|
||||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||||
translate(
|
translate(
|
||||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||||
style: style,
|
style: style,
|
||||||
)),
|
)),
|
||||||
proc: () {
|
proc: () {
|
||||||
toolbarState.switchShow(sessionId);
|
toolbarState.switchHide(sessionId);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
padding: padding,
|
padding: padding,
|
||||||
@@ -316,7 +330,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
translate('Close'),
|
translate('Close'),
|
||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
proc: () {
|
proc: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tabController.closeBy(key);
|
tabController.closeBy(key);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
@@ -360,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
loopCloseWindow();
|
loopCloseWindow();
|
||||||
}
|
}
|
||||||
ConnectionTypeState.delete(id);
|
ConnectionTypeState.delete(id);
|
||||||
|
// Clean up relative mouse mode state for this peer.
|
||||||
|
stateGlobal.relativeMouseModeState.remove(id);
|
||||||
_update_remote_count();
|
_update_remote_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +391,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.length;
|
final connLength = tabController.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -423,7 +453,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: RemotePage(
|
page: RemotePage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
@@ -518,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
return returnValue;
|
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(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_buildClientAvatar().marginOnly(right: 10.0),
|
||||||
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),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
@@ -582,6 +566,36 @@ class _CmHeaderState extends State<_CmHeader>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
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 {
|
class _PrivilegeBoard extends StatefulWidget {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
@@ -8,13 +9,14 @@ import 'package:xterm/xterm.dart';
|
|||||||
import 'terminal_connection_manager.dart';
|
import 'terminal_connection_manager.dart';
|
||||||
|
|
||||||
class TerminalPage extends StatefulWidget {
|
class TerminalPage extends StatefulWidget {
|
||||||
const TerminalPage({
|
TerminalPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.tabController,
|
required this.tabController,
|
||||||
required this.isSharedPassword,
|
required this.isSharedPassword,
|
||||||
required this.terminalId,
|
required this.terminalId,
|
||||||
|
required this.tabKey,
|
||||||
this.forceRelay,
|
this.forceRelay,
|
||||||
this.connToken,
|
this.connToken,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@@ -25,20 +27,35 @@ class TerminalPage extends StatefulWidget {
|
|||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final int terminalId;
|
final int terminalId;
|
||||||
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||||
|
final String tabKey;
|
||||||
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TerminalPage> createState() => _TerminalPageState();
|
State<TerminalPage> createState() {
|
||||||
|
final state = _TerminalPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
|
double? _cellHeight;
|
||||||
|
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||||
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Listen for tab selection changes to request focus
|
||||||
|
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
|
||||||
|
|
||||||
// Use shared FFI instance from connection manager
|
// Use shared FFI instance from connection manager
|
||||||
_ffi = TerminalConnectionManager.getConnection(
|
_ffi = TerminalConnectionManager.getConnection(
|
||||||
peerId: widget.id,
|
peerId: widget.id,
|
||||||
@@ -53,6 +70,24 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||||
|
|
||||||
|
_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) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Register this terminal model with FFI for event routing
|
// Register this terminal model with FFI for event routing
|
||||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||||
|
|
||||||
@@ -62,8 +97,9 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
|
|
||||||
// Check if this is a new connection or additional terminal
|
// Check if this is a new connection or additional terminal
|
||||||
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
||||||
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
|
final isExistingConnection =
|
||||||
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
TerminalConnectionManager.hasConnection(widget.id) &&
|
||||||
|
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
||||||
|
|
||||||
if (!isExistingConnection) {
|
if (!isExistingConnection) {
|
||||||
// First terminal - show loading dialog, wait for onReady
|
// First terminal - show loading dialog, wait for onReady
|
||||||
@@ -79,38 +115,86 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Cancel tab state subscription to prevent memory leak
|
||||||
|
_tabStateSubscription?.cancel();
|
||||||
// Unregister terminal model from FFI
|
// Unregister terminal model from FFI
|
||||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||||
_terminalModel.dispose();
|
_terminalModel.dispose();
|
||||||
|
_terminalFocusNode.dispose();
|
||||||
// Release connection reference instead of closing directly
|
// Release connection reference instead of closing directly
|
||||||
TerminalConnectionManager.releaseConnection(widget.id);
|
TerminalConnectionManager.releaseConnection(widget.id);
|
||||||
super.dispose();
|
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 rows = (heightPx / _cellHeight!).floor();
|
||||||
|
final extraSpace = heightPx - rows * _cellHeight!;
|
||||||
|
final topBottom = extraSpace / 2.0;
|
||||||
|
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: TerminalView(
|
body: LayoutBuilder(
|
||||||
_terminalModel.terminal,
|
builder: (context, constraints) {
|
||||||
controller: _terminalModel.terminalController,
|
final heightPx = constraints.maxHeight;
|
||||||
autofocus: true,
|
return TerminalView(
|
||||||
backgroundOpacity: 0.7,
|
_terminalModel.terminal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
controller: _terminalModel.terminalController,
|
||||||
onSecondaryTapDown: (details, offset) async {
|
focusNode: _terminalFocusNode,
|
||||||
final selection = _terminalModel.terminalController.selection;
|
// Note: autofocus is not used here because focus is managed manually
|
||||||
if (selection != null) {
|
// via _onTabStateChanged() to handle tab switching properly.
|
||||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
backgroundOpacity: 0.7,
|
||||||
_terminalModel.terminalController.clearSelection();
|
padding: _calculatePadding(heightPx),
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
onSecondaryTapDown: (details, offset) async {
|
||||||
} else {
|
final selection = _terminalModel.terminalController.selection;
|
||||||
final data = await Clipboard.getData('text/plain');
|
if (selection != null) {
|
||||||
final text = data?.text;
|
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||||
if (text != null) {
|
_terminalModel.terminalController.clearSelection();
|
||||||
_terminalModel.terminal.paste(text);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
@@ -33,6 +34,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
static const IconData selectedIcon = Icons.terminal;
|
static const IconData selectedIcon = Icons.terminal;
|
||||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||||
int _nextTerminalId = 1;
|
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) {
|
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||||
@@ -61,27 +66,20 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
String? connToken,
|
String? connToken,
|
||||||
}) {
|
}) {
|
||||||
final tabKey = '${peerId}_$terminalId';
|
final tabKey = '${peerId}_$terminalId';
|
||||||
|
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||||
|
final tabLabel =
|
||||||
|
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||||
return TabInfo(
|
return TabInfo(
|
||||||
key: tabKey,
|
key: tabKey,
|
||||||
label: '$peerId #$terminalId',
|
label: tabLabel,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () async {
|
onTabCloseButton: () => _closeTab(tabKey),
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
page: TerminalPage(
|
page: TerminalPage(
|
||||||
key: ValueKey(tabKey),
|
key: ValueKey(tabKey),
|
||||||
id: peerId,
|
id: peerId,
|
||||||
terminalId: terminalId,
|
terminalId: terminalId,
|
||||||
|
tabKey: tabKey,
|
||||||
password: password,
|
password: password,
|
||||||
isSharedPassword: isSharedPassword,
|
isSharedPassword: isSharedPassword,
|
||||||
tabController: tabController,
|
tabController: tabController,
|
||||||
@@ -91,6 +89,159 @@ 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())
|
||||||
|
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) {
|
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
|
||||||
final List<MenuEntryBase<String>> menu = [];
|
final List<MenuEntryBase<String>> menu = [];
|
||||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||||
@@ -174,7 +325,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
} else if (call.method == kWindowEventRestoreTerminalSessions) {
|
} else if (call.method == kWindowEventRestoreTerminalSessions) {
|
||||||
_restoreSessions(call.arguments);
|
_restoreSessions(call.arguments);
|
||||||
} else if (call.method == "onDestroy") {
|
} else if (call.method == "onDestroy") {
|
||||||
tabController.clear();
|
// Clean up sessions before window destruction (bounded wait)
|
||||||
|
await _closeAllTabs();
|
||||||
} else if (call.method == kWindowActionRebuild) {
|
} else if (call.method == kWindowActionRebuild) {
|
||||||
reloadCurrentWindow();
|
reloadCurrentWindow();
|
||||||
} else if (call.method == kWindowEventActiveSession) {
|
} else if (call.method == kWindowEventActiveSession) {
|
||||||
@@ -184,7 +336,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
assert(call.arguments is String,
|
assert(call.arguments is String,
|
||||||
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
|
"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());
|
windowOnTop(windowId());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -255,7 +410,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
// macOS: Cmd+W (standard for close tab)
|
// macOS: Cmd+W (standard for close tab)
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
if (tabController.state.value.tabs.length > 1) {
|
if (tabController.state.value.tabs.length > 1) {
|
||||||
tabController.closeBy(currentTab.key);
|
_closeTab(currentTab.key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (!isMacOS &&
|
} else if (!isMacOS &&
|
||||||
@@ -264,7 +419,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
if (tabController.state.value.tabs.length > 1) {
|
if (tabController.state.value.tabs.length > 1) {
|
||||||
tabController.closeBy(currentTab.key);
|
_closeTab(currentTab.key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +474,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
void _addNewTerminal(String peerId, {int? terminalId}) {
|
void _addNewTerminal(String peerId, {int? terminalId}) {
|
||||||
// Find first tab for this peer to get connection parameters
|
// Find first tab for this peer to get connection parameters
|
||||||
final firstTab = tabController.state.value.tabs.firstWhere(
|
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) {
|
if (firstTab.page is TerminalPage) {
|
||||||
final page = firstTab.page as TerminalPage;
|
final page = firstTab.page as TerminalPage;
|
||||||
@@ -340,11 +498,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
|
|
||||||
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
final parts = currentTab.key.split('_');
|
final parsed = _parseTabKey(currentTab.key);
|
||||||
if (parts.isNotEmpty) {
|
if (parsed == null) return;
|
||||||
final peerId = parts[0];
|
final (peerId, _) = parsed;
|
||||||
_addNewTerminal(peerId, terminalId: terminalId);
|
_addNewTerminal(peerId, terminalId: terminalId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -358,10 +515,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
selectedBorderColor: MyTheme.accent,
|
selectedBorderColor: MyTheme.accent,
|
||||||
labelGetter: DesktopTab.tablabelGetter,
|
labelGetter: DesktopTab.tablabelGetter,
|
||||||
tabMenuBuilder: (key) {
|
tabMenuBuilder: (key) {
|
||||||
// Extract peerId from tab key (format: "peerId_terminalId")
|
final parsed = _parseTabKey(key);
|
||||||
final parts = key.split('_');
|
if (parsed == null) return Container();
|
||||||
if (parts.isEmpty) return Container();
|
final (peerId, _) = parsed;
|
||||||
final peerId = parts[0];
|
|
||||||
return _tabMenuBuilder(peerId, () {});
|
return _tabMenuBuilder(peerId, () {});
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -407,8 +563,16 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.state.value.tabs.length;
|
final connLength = tabController.state.value.tabs.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
await _closeAllTabs();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
final bool res;
|
final bool res;
|
||||||
@@ -419,7 +583,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
res = await closeConfirmDialog();
|
res = await closeConfirmDialog();
|
||||||
}
|
}
|
||||||
if (res) {
|
if (res) {
|
||||||
tabController.clear();
|
await _closeAllTabs();
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
|
|
||||||
import '../../consts.dart';
|
import '../../consts.dart';
|
||||||
@@ -77,6 +76,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
String keyboardMode = "legacy";
|
String keyboardMode = "legacy";
|
||||||
bool _isWindowBlur = false;
|
bool _isWindowBlur = false;
|
||||||
final _cursorOverImage = false.obs;
|
final _cursorOverImage = false.obs;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
var _blockableOverlayState = BlockableOverlayState();
|
var _blockableOverlayState = BlockableOverlayState();
|
||||||
|
|
||||||
@@ -124,9 +124,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
_ffi.dialogManager
|
_ffi.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||||
@@ -185,26 +183,20 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||||
@override
|
@override
|
||||||
void onWindowMaximize() {
|
void onWindowMaximize() {
|
||||||
super.onWindowMaximize();
|
super.onWindowMaximize();
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowMinimize() {
|
void onWindowMinimize() {
|
||||||
super.onWindowMinimize();
|
super.onWindowMinimize();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -247,9 +239,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
await Get.delete<FFI>(tag: widget.id);
|
await Get.delete<FFI>(tag: widget.id);
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
}
|
}
|
||||||
@@ -360,7 +350,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
super.build(context);
|
super.build(context);
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, _ffi.dialogManager);
|
clientClose(sessionId, _ffi);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: MultiProvider(providers: [
|
child: MultiProvider(providers: [
|
||||||
@@ -465,7 +455,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||||
? Container(color: Colors.transparent)
|
? Container(color: Colors.transparent)
|
||||||
: Obx(() {
|
: Obx(() {
|
||||||
widget.toolbarState.initShow(sessionId);
|
|
||||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||||
return ImagePaint(
|
return ImagePaint(
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
@@ -527,7 +516,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
|
|
||||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||||
|
|
||||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
final paintWidth = c.getDisplayWidth() * s;
|
final paintWidth = c.getDisplayWidth() * s;
|
||||||
final paintHeight = c.getDisplayHeight() * s;
|
final paintHeight = c.getDisplayHeight() * s;
|
||||||
final paintSize = Size(paintWidth, paintHeight);
|
final paintSize = Size(paintWidth, paintHeight);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/input_model.dart';
|
import 'package:flutter_hbb/models/input_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
@@ -79,7 +80,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
label: peerId!,
|
label: peerId!,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: peerId!,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(peerId!);
|
||||||
|
},
|
||||||
page: ViewCameraPage(
|
page: ViewCameraPage(
|
||||||
key: ValueKey(peerId),
|
key: ValueKey(peerId),
|
||||||
id: peerId!,
|
id: peerId!,
|
||||||
@@ -241,11 +250,11 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
MenuEntryButton<String>(
|
MenuEntryButton<String>(
|
||||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||||
translate(
|
translate(
|
||||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||||
style: style,
|
style: style,
|
||||||
)),
|
)),
|
||||||
proc: () {
|
proc: () {
|
||||||
toolbarState.switchShow(sessionId);
|
toolbarState.switchHide(sessionId);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
padding: padding,
|
padding: padding,
|
||||||
@@ -287,7 +296,13 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
translate('Close'),
|
translate('Close'),
|
||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
proc: () {
|
proc: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tabController.closeBy(key);
|
tabController.closeBy(key);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
@@ -340,6 +355,14 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.length;
|
final connLength = tabController.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -393,7 +416,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: ViewCameraPage(
|
page: ViewCameraPage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ import '../../models/platform_model.dart';
|
|||||||
import '../../common/shared_state.dart';
|
import '../../common/shared_state.dart';
|
||||||
import './popup_menu.dart';
|
import './popup_menu.dart';
|
||||||
import './kb_layout_type_chooser.dart';
|
import './kb_layout_type_chooser.dart';
|
||||||
|
import 'package:flutter_hbb/utils/scale.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
||||||
|
|
||||||
class ToolbarState {
|
class ToolbarState {
|
||||||
late RxBool _pin;
|
late RxBool _pin;
|
||||||
|
|
||||||
bool isShowInited = false;
|
RxBool collapse = false.obs;
|
||||||
RxBool show = false.obs;
|
RxBool hide = false.obs;
|
||||||
|
|
||||||
|
// Track initialization state to prevent flickering
|
||||||
|
final RxBool initialized = false.obs;
|
||||||
|
bool _isInitializing = false;
|
||||||
|
|
||||||
ToolbarState() {
|
ToolbarState() {
|
||||||
_pin = RxBool(false);
|
_pin = RxBool(false);
|
||||||
@@ -51,19 +57,39 @@ class ToolbarState {
|
|||||||
|
|
||||||
bool get pin => _pin.value;
|
bool get pin => _pin.value;
|
||||||
|
|
||||||
switchShow(SessionID sessionId) async {
|
/// Initialize all toolbar states from session options.
|
||||||
bind.sessionToggleOption(
|
/// This should be called once when the toolbar is first created.
|
||||||
sessionId: sessionId, value: kOptionCollapseToolbar);
|
Future<void> init(SessionID sessionId) async {
|
||||||
show.value = !show.value;
|
if (initialized.value || _isInitializing) return;
|
||||||
|
_isInitializing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load both states in parallel for better performance
|
||||||
|
final results = await Future.wait([
|
||||||
|
bind.sessionGetToggleOption(
|
||||||
|
sessionId: sessionId, arg: kOptionCollapseToolbar),
|
||||||
|
bind.sessionGetToggleOption(
|
||||||
|
sessionId: sessionId, arg: kOptionHideToolbar),
|
||||||
|
]);
|
||||||
|
|
||||||
|
collapse.value = results[0] ?? false;
|
||||||
|
hide.value = results[1] ?? false;
|
||||||
|
} finally {
|
||||||
|
_isInitializing = false;
|
||||||
|
initialized.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initShow(SessionID sessionId) async {
|
switchCollapse(SessionID sessionId) async {
|
||||||
if (!isShowInited) {
|
bind.sessionToggleOption(
|
||||||
show.value = !(await bind.sessionGetToggleOption(
|
sessionId: sessionId, value: kOptionCollapseToolbar);
|
||||||
sessionId: sessionId, arg: kOptionCollapseToolbar) ??
|
collapse.value = !collapse.value;
|
||||||
false);
|
}
|
||||||
isShowInited = true;
|
|
||||||
}
|
// Switch hide state for entire toolbar visibility
|
||||||
|
switchHide(SessionID sessionId) async {
|
||||||
|
bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar);
|
||||||
|
hide.value = !hide.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
switchPin() async {
|
switchPin() async {
|
||||||
@@ -152,129 +178,6 @@ class _ToolbarTheme {
|
|||||||
typedef DismissFunc = void Function();
|
typedef DismissFunc = void Function();
|
||||||
|
|
||||||
class RemoteMenuEntry {
|
class RemoteMenuEntry {
|
||||||
static MenuEntryRadios<String> viewStyle(
|
|
||||||
String remoteId,
|
|
||||||
FFI ffi,
|
|
||||||
EdgeInsets padding, {
|
|
||||||
DismissFunc? dismissFunc,
|
|
||||||
DismissCallback? dismissCallback,
|
|
||||||
RxString? rxViewStyle,
|
|
||||||
}) {
|
|
||||||
return MenuEntryRadios<String>(
|
|
||||||
text: translate('Ratio'),
|
|
||||||
optionsGetter: () => [
|
|
||||||
MenuEntryRadioOption(
|
|
||||||
text: translate('Scale original'),
|
|
||||||
value: kRemoteViewStyleOriginal,
|
|
||||||
dismissOnClicked: true,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
),
|
|
||||||
MenuEntryRadioOption(
|
|
||||||
text: translate('Scale adaptive'),
|
|
||||||
value: kRemoteViewStyleAdaptive,
|
|
||||||
dismissOnClicked: true,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
curOptionGetter: () async {
|
|
||||||
// null means peer id is not found, which there's no need to care about
|
|
||||||
final viewStyle =
|
|
||||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
|
||||||
if (rxViewStyle != null) {
|
|
||||||
rxViewStyle.value = viewStyle;
|
|
||||||
}
|
|
||||||
return viewStyle;
|
|
||||||
},
|
|
||||||
optionSetter: (String oldValue, String newValue) async {
|
|
||||||
await bind.sessionSetViewStyle(
|
|
||||||
sessionId: ffi.sessionId, value: newValue);
|
|
||||||
if (rxViewStyle != null) {
|
|
||||||
rxViewStyle.value = newValue;
|
|
||||||
}
|
|
||||||
ffi.canvasModel.updateViewStyle();
|
|
||||||
if (dismissFunc != null) {
|
|
||||||
dismissFunc();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
padding: padding,
|
|
||||||
dismissOnClicked: true,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static MenuEntrySwitch2<String> showRemoteCursor(
|
|
||||||
String remoteId,
|
|
||||||
SessionID sessionId,
|
|
||||||
EdgeInsets padding, {
|
|
||||||
DismissFunc? dismissFunc,
|
|
||||||
DismissCallback? dismissCallback,
|
|
||||||
}) {
|
|
||||||
final state = ShowRemoteCursorState.find(remoteId);
|
|
||||||
final optKey = 'show-remote-cursor';
|
|
||||||
return MenuEntrySwitch2<String>(
|
|
||||||
switchType: SwitchType.scheckbox,
|
|
||||||
text: translate('Show remote cursor'),
|
|
||||||
getter: () {
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
setter: (bool v) async {
|
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: optKey);
|
|
||||||
state.value =
|
|
||||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: optKey);
|
|
||||||
if (dismissFunc != null) {
|
|
||||||
dismissFunc();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
padding: padding,
|
|
||||||
dismissOnClicked: true,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static MenuEntrySwitch<String> disableClipboard(
|
|
||||||
SessionID sessionId,
|
|
||||||
EdgeInsets? padding, {
|
|
||||||
DismissFunc? dismissFunc,
|
|
||||||
DismissCallback? dismissCallback,
|
|
||||||
}) {
|
|
||||||
return createSwitchMenuEntry(
|
|
||||||
sessionId,
|
|
||||||
'Disable clipboard',
|
|
||||||
'disable-clipboard',
|
|
||||||
padding,
|
|
||||||
true,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static MenuEntrySwitch<String> createSwitchMenuEntry(
|
|
||||||
SessionID sessionId,
|
|
||||||
String text,
|
|
||||||
String option,
|
|
||||||
EdgeInsets? padding,
|
|
||||||
bool dismissOnClicked, {
|
|
||||||
DismissFunc? dismissFunc,
|
|
||||||
DismissCallback? dismissCallback,
|
|
||||||
}) {
|
|
||||||
return MenuEntrySwitch<String>(
|
|
||||||
switchType: SwitchType.scheckbox,
|
|
||||||
text: translate(text),
|
|
||||||
getter: () async {
|
|
||||||
return bind.sessionGetToggleOptionSync(
|
|
||||||
sessionId: sessionId, arg: option);
|
|
||||||
},
|
|
||||||
setter: (bool v) async {
|
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
|
||||||
if (dismissFunc != null) {
|
|
||||||
dismissFunc();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
padding: padding,
|
|
||||||
dismissOnClicked: dismissOnClicked,
|
|
||||||
dismissCallback: dismissCallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static MenuEntryButton<String> insertLock(
|
static MenuEntryButton<String> insertLock(
|
||||||
SessionID sessionId,
|
SessionID sessionId,
|
||||||
EdgeInsets? padding, {
|
EdgeInsets? padding, {
|
||||||
@@ -358,7 +261,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
// setState(() {});
|
// setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
RxBool get show => widget.state.show;
|
RxBool get collapse => widget.state.collapse;
|
||||||
|
RxBool get hide => widget.state.hide;
|
||||||
bool get pin => widget.state.pin;
|
bool get pin => widget.state.pin;
|
||||||
|
|
||||||
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
||||||
@@ -379,6 +283,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
arg: 'remote-menubar-drag-x') ??
|
arg: 'remote-menubar-drag-x') ??
|
||||||
'0.5') ??
|
'0.5') ??
|
||||||
0.5;
|
0.5;
|
||||||
|
// Initialize toolbar states (collapse, hide) from session options
|
||||||
|
widget.state.init(widget.ffi.sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
_debouncerHide = Debouncer<int>(
|
_debouncerHide = Debouncer<int>(
|
||||||
@@ -398,8 +304,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_debouncerHideProc(int v) {
|
_debouncerHideProc(int v) {
|
||||||
if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) {
|
if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) {
|
||||||
show.value = false;
|
collapse.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,17 +318,27 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Align(
|
return Obx(() {
|
||||||
alignment: Alignment.topCenter,
|
// Wait for initialization to complete to prevent flickering
|
||||||
child: Obx(() => show.value
|
if (!widget.state.initialized.value) {
|
||||||
? _buildToolbar(context)
|
return const SizedBox.shrink();
|
||||||
: _buildDraggableShowHide(context)),
|
}
|
||||||
);
|
// If toolbar is hidden, return empty widget
|
||||||
|
if (hide.value) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: collapse.isFalse
|
||||||
|
? _buildToolbar(context)
|
||||||
|
: _buildDraggableCollapse(context),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDraggableShowHide(BuildContext context) {
|
Widget _buildDraggableCollapse(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (show.isTrue && _dragging.isFalse) {
|
if (collapse.isFalse && _dragging.isFalse) {
|
||||||
triggerAutoHide();
|
triggerAutoHide();
|
||||||
}
|
}
|
||||||
final borderRadius = BorderRadius.vertical(
|
final borderRadius = BorderRadius.vertical(
|
||||||
@@ -519,7 +435,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildDraggableShowHide(context),
|
_buildDraggableCollapse(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -632,7 +548,7 @@ class _MonitorMenu extends StatelessWidget {
|
|||||||
menuStyle: MenuStyle(
|
menuStyle: MenuStyle(
|
||||||
padding:
|
padding:
|
||||||
MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))),
|
MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))),
|
||||||
menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]);
|
menuChildrenGetter: (_) => [buildMonitorSubmenuWidget(context)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildMultiMonitorMenu(BuildContext context) {
|
Widget buildMultiMonitorMenu(BuildContext context) {
|
||||||
@@ -843,7 +759,7 @@ class _ControlMenu extends StatelessWidget {
|
|||||||
color: _ToolbarTheme.blueColor,
|
color: _ToolbarTheme.blueColor,
|
||||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||||
ffi: ffi,
|
ffi: ffi,
|
||||||
menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) {
|
menuChildrenGetter: (_) => toolbarControls(context, id, ffi).map((e) {
|
||||||
if (e.divider) {
|
if (e.divider) {
|
||||||
return Divider();
|
return Divider();
|
||||||
} else {
|
} else {
|
||||||
@@ -1024,6 +940,7 @@ class _DisplayMenu extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DisplayMenuState extends State<_DisplayMenu> {
|
class _DisplayMenuState extends State<_DisplayMenu> {
|
||||||
|
final RxInt _customPercent = 100.obs;
|
||||||
late final ScreenAdjustor _screenAdjustor = ScreenAdjustor(
|
late final ScreenAdjustor _screenAdjustor = ScreenAdjustor(
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
ffi: widget.ffi,
|
ffi: widget.ffi,
|
||||||
@@ -1037,14 +954,29 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
|||||||
FFI get ffi => widget.ffi;
|
FFI get ffi => widget.ffi;
|
||||||
String get id => widget.id;
|
String get id => widget.id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Initialize custom percent from stored option once
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
try {
|
||||||
|
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
||||||
|
if (_customPercent.value != v) {
|
||||||
|
_customPercent.value = v;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
_screenAdjustor.updateScreen();
|
_screenAdjustor.updateScreen();
|
||||||
menuChildrenGetter() {
|
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||||
final menuChildren = <Widget>[
|
final menuChildren = <Widget>[
|
||||||
_screenAdjustor.adjustWindow(context),
|
_screenAdjustor.adjustWindow(context),
|
||||||
viewStyle(),
|
viewStyle(customPercent: _customPercent),
|
||||||
scrollStyle(),
|
scrollStyle(state, colorScheme),
|
||||||
imageQuality(),
|
imageQuality(),
|
||||||
codec(),
|
codec(),
|
||||||
if (ffi.connType == ConnType.defaultConn)
|
if (ffi.connType == ConnType.defaultConn)
|
||||||
@@ -1108,62 +1040,146 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewStyle() {
|
viewStyle({required RxInt customPercent}) {
|
||||||
return futureBuilder(
|
return futureBuilder(
|
||||||
future: toolbarViewStyle(context, widget.id, widget.ffi),
|
future: toolbarViewStyle(context, widget.id, widget.ffi),
|
||||||
hasData: (data) {
|
hasData: (data) {
|
||||||
final v = data as List<TRadioMenu<String>>;
|
final v = data as List<TRadioMenu<String>>;
|
||||||
|
final bool isCustomSelected = v.isNotEmpty
|
||||||
|
? v.first.groupValue == kRemoteViewStyleCustom
|
||||||
|
: false;
|
||||||
return Column(children: [
|
return Column(children: [
|
||||||
...v
|
...v.map((e) {
|
||||||
.map((e) => RdoMenuButton<String>(
|
final isCustom = e.value == kRemoteViewStyleCustom;
|
||||||
value: e.value,
|
final child =
|
||||||
groupValue: e.groupValue,
|
isCustom ? Text(translate('Scale custom')) : e.child;
|
||||||
onChanged: e.onChanged,
|
// Whether the current selection is already custom
|
||||||
child: e.child,
|
final bool isGroupCustomSelected =
|
||||||
ffi: ffi))
|
e.groupValue == kRemoteViewStyleCustom;
|
||||||
.toList(),
|
// Keep menu open when switching INTO custom so the slider is visible immediately
|
||||||
Divider(),
|
final bool keepOpenForThisItem =
|
||||||
|
isCustom && !isGroupCustomSelected;
|
||||||
|
return RdoMenuButton<String>(
|
||||||
|
value: e.value,
|
||||||
|
groupValue: e.groupValue,
|
||||||
|
onChanged: (value) {
|
||||||
|
// Perform the original change
|
||||||
|
e.onChanged?.call(value);
|
||||||
|
// Only force a rebuild when we keep the menu open to reveal the slider
|
||||||
|
if (keepOpenForThisItem) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
ffi: ffi,
|
||||||
|
// When entering custom, keep submenu open to show the slider controls
|
||||||
|
closeOnActivate: !keepOpenForThisItem);
|
||||||
|
}).toList(),
|
||||||
|
// Only show a divider when custom is NOT selected
|
||||||
|
if (!isCustomSelected) Divider(),
|
||||||
|
_customControlsIfCustomSelected(
|
||||||
|
onChanged: (v) => customPercent.value = v),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollStyle() {
|
Widget _customControlsIfCustomSelected({ValueChanged<int>? onChanged}) {
|
||||||
|
return futureBuilder(future: () async {
|
||||||
|
final current = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||||
|
return current == kRemoteViewStyleCustom;
|
||||||
|
}(), hasData: (data) {
|
||||||
|
final isCustom = data as bool;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: Duration(milliseconds: 220),
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
child: isCustom
|
||||||
|
? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged)
|
||||||
|
: SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollStyle(_IconSubmenuButtonState state, ColorScheme colorScheme) {
|
||||||
return futureBuilder(future: () async {
|
return futureBuilder(future: () async {
|
||||||
final viewStyle =
|
final viewStyle =
|
||||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||||
final visible = viewStyle == kRemoteViewStyleOriginal;
|
final visible = viewStyle == kRemoteViewStyleOriginal ||
|
||||||
|
viewStyle == kRemoteViewStyleCustom;
|
||||||
final scrollStyle =
|
final scrollStyle =
|
||||||
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
|
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
|
||||||
return {'visible': visible, 'scrollStyle': scrollStyle};
|
final edgeScrollEdgeThickness = await bind
|
||||||
|
.sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId);
|
||||||
|
return {
|
||||||
|
'visible': visible,
|
||||||
|
'scrollStyle': scrollStyle,
|
||||||
|
'edgeScrollEdgeThickness': edgeScrollEdgeThickness,
|
||||||
|
};
|
||||||
}(), hasData: (data) {
|
}(), hasData: (data) {
|
||||||
final visible = data['visible'] as bool;
|
final visible = data['visible'] as bool;
|
||||||
if (!visible) return Offstage();
|
if (!visible) return Offstage();
|
||||||
final groupValue = data['scrollStyle'] as String;
|
final groupValue = data['scrollStyle'] as String;
|
||||||
onChange(String? value) async {
|
final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int;
|
||||||
|
|
||||||
|
onChangeScrollStyle(String? value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionSetScrollStyle(
|
await bind.sessionSetScrollStyle(
|
||||||
sessionId: ffi.sessionId, value: value);
|
sessionId: ffi.sessionId, value: value);
|
||||||
widget.ffi.canvasModel.updateScrollStyle();
|
widget.ffi.canvasModel.updateScrollStyle();
|
||||||
|
state.setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
final enabled = widget.ffi.canvasModel.imageOverflow.value;
|
onChangeEdgeScrollEdgeThickness(double? value) async {
|
||||||
return Column(children: [
|
if (value == null) return;
|
||||||
RdoMenuButton<String>(
|
final newThickness = value.round();
|
||||||
child: Text(translate('ScrollAuto')),
|
await bind.sessionSetEdgeScrollEdgeThickness(
|
||||||
value: kRemoteScrollStyleAuto,
|
sessionId: ffi.sessionId, value: newThickness);
|
||||||
groupValue: groupValue,
|
widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness);
|
||||||
onChanged: enabled ? (value) => onChange(value) : null,
|
state.setState(() {});
|
||||||
ffi: widget.ffi,
|
}
|
||||||
),
|
|
||||||
RdoMenuButton<String>(
|
return Obx(() => Column(children: [
|
||||||
child: Text(translate('Scrollbar')),
|
RdoMenuButton<String>(
|
||||||
value: kRemoteScrollStyleBar,
|
child: Text(translate('ScrollAuto')),
|
||||||
groupValue: groupValue,
|
value: kRemoteScrollStyleAuto,
|
||||||
onChanged: enabled ? (value) => onChange(value) : null,
|
groupValue: groupValue,
|
||||||
ffi: widget.ffi,
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||||
),
|
? (value) => onChangeScrollStyle(value)
|
||||||
Divider(),
|
: null,
|
||||||
]);
|
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
||||||
|
ffi: widget.ffi,
|
||||||
|
),
|
||||||
|
RdoMenuButton<String>(
|
||||||
|
child: Text(translate('Scrollbar')),
|
||||||
|
value: kRemoteScrollStyleBar,
|
||||||
|
groupValue: groupValue,
|
||||||
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||||
|
? (value) => onChangeScrollStyle(value)
|
||||||
|
: null,
|
||||||
|
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
||||||
|
ffi: widget.ffi,
|
||||||
|
),
|
||||||
|
if (!isWeb) ...[
|
||||||
|
RdoMenuButton<String>(
|
||||||
|
child: Text(translate('ScrollEdge')),
|
||||||
|
value: kRemoteScrollStyleEdge,
|
||||||
|
groupValue: groupValue,
|
||||||
|
closeOnActivate: false,
|
||||||
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||||
|
? (value) => onChangeScrollStyle(value)
|
||||||
|
: null,
|
||||||
|
ffi: widget.ffi,
|
||||||
|
),
|
||||||
|
Offstage(
|
||||||
|
offstage: groupValue != kRemoteScrollStyleEdge,
|
||||||
|
child: EdgeThicknessControl(
|
||||||
|
value: edgeScrollEdgeThickness.toDouble(),
|
||||||
|
onChanged: onChangeEdgeScrollEdgeThickness,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
Divider(),
|
||||||
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,6 +1261,178 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CustomScaleMenuControls extends StatefulWidget {
|
||||||
|
final FFI ffi;
|
||||||
|
final ValueChanged<int>? onChanged;
|
||||||
|
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CustomScaleMenuControls> createState() =>
|
||||||
|
_CustomScaleMenuControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomScaleMenuControlsState
|
||||||
|
extends CustomScaleControls<_CustomScaleMenuControls> {
|
||||||
|
@override
|
||||||
|
FFI get ffi => widget.ffi;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
const smallBtnConstraints = BoxConstraints(minWidth: 28, minHeight: 28);
|
||||||
|
|
||||||
|
final sliderControl = Semantics(
|
||||||
|
label: translate('Custom scale slider'),
|
||||||
|
value: '$scaleValue%',
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
activeTrackColor: colorScheme.primary,
|
||||||
|
thumbColor: colorScheme.primary,
|
||||||
|
overlayColor: colorScheme.primary.withOpacity(0.1),
|
||||||
|
showValueIndicator: ShowValueIndicator.never,
|
||||||
|
thumbShape: _RectValueThumbShape(
|
||||||
|
min: CustomScaleControls.minPercent.toDouble(),
|
||||||
|
max: CustomScaleControls.maxPercent.toDouble(),
|
||||||
|
width: 52,
|
||||||
|
height: 24,
|
||||||
|
radius: 4,
|
||||||
|
displayValueForNormalized: (t) => mapPosToPercent(t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: scalePos,
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
// Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.minPercent)) to provide ~1% precision increments.
|
||||||
|
// This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges.
|
||||||
|
divisions:
|
||||||
|
(CustomScaleControls.maxPercent - CustomScaleControls.minPercent)
|
||||||
|
.round(),
|
||||||
|
onChanged: onSliderChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(children: [
|
||||||
|
Tooltip(
|
||||||
|
message: translate('Decrease'),
|
||||||
|
child: IconButton(
|
||||||
|
iconSize: 16,
|
||||||
|
padding: EdgeInsets.all(1),
|
||||||
|
constraints: smallBtnConstraints,
|
||||||
|
icon: const Icon(Icons.remove),
|
||||||
|
onPressed: () => nudgeScale(-1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: sliderControl),
|
||||||
|
Tooltip(
|
||||||
|
message: translate('Increase'),
|
||||||
|
child: IconButton(
|
||||||
|
iconSize: 16,
|
||||||
|
padding: EdgeInsets.all(1),
|
||||||
|
constraints: smallBtnConstraints,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
onPressed: () => nudgeScale(1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
Divider(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight rectangular thumb that paints the current percentage.
|
||||||
|
// Stateless and uses only SliderTheme colors; avoids allocations beyond a TextPainter per frame.
|
||||||
|
class _RectValueThumbShape extends SliderComponentShape {
|
||||||
|
final double min;
|
||||||
|
final double max;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double radius;
|
||||||
|
final String unit;
|
||||||
|
// Optional mapper to compute display value from normalized position [0,1]
|
||||||
|
// If null, falls back to linear interpolation between min and max.
|
||||||
|
final int Function(double normalized)? displayValueForNormalized;
|
||||||
|
|
||||||
|
const _RectValueThumbShape({
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
required this.radius,
|
||||||
|
this.displayValueForNormalized,
|
||||||
|
this.unit = '%',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size getPreferredSize(bool isEnabled, bool isDiscrete) {
|
||||||
|
return Size(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(
|
||||||
|
PaintingContext context,
|
||||||
|
Offset center, {
|
||||||
|
required Animation<double> activationAnimation,
|
||||||
|
required Animation<double> enableAnimation,
|
||||||
|
required bool isDiscrete,
|
||||||
|
required TextPainter labelPainter,
|
||||||
|
required RenderBox parentBox,
|
||||||
|
required SliderThemeData sliderTheme,
|
||||||
|
required TextDirection textDirection,
|
||||||
|
required double value,
|
||||||
|
required double textScaleFactor,
|
||||||
|
required Size sizeWithOverflow,
|
||||||
|
}) {
|
||||||
|
final Canvas canvas = context.canvas;
|
||||||
|
|
||||||
|
// Resolve color based on enabled/disabled animation, with safe fallbacks.
|
||||||
|
final ColorTween colorTween = ColorTween(
|
||||||
|
begin: sliderTheme.disabledThumbColor,
|
||||||
|
end: sliderTheme.thumbColor,
|
||||||
|
);
|
||||||
|
final Color? evaluatedColor = colorTween.evaluate(enableAnimation);
|
||||||
|
final Color? thumbColor = sliderTheme.thumbColor;
|
||||||
|
final Color fillColor = evaluatedColor ?? thumbColor ?? Colors.blueAccent;
|
||||||
|
|
||||||
|
final RRect rrect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(center: center, width: width, height: height),
|
||||||
|
Radius.circular(radius),
|
||||||
|
);
|
||||||
|
final Paint paint = Paint()..color = fillColor;
|
||||||
|
canvas.drawRRect(rrect, paint);
|
||||||
|
|
||||||
|
// Compute displayed value from normalized slider value.
|
||||||
|
final int displayValue = displayValueForNormalized != null
|
||||||
|
? displayValueForNormalized!(value)
|
||||||
|
: (min + value * (max - min)).round();
|
||||||
|
final TextSpan span = TextSpan(
|
||||||
|
text: '$displayValue$unit',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final TextPainter tp = TextPainter(
|
||||||
|
text: span,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textDirection: textDirection,
|
||||||
|
);
|
||||||
|
tp.layout(maxWidth: width - 4);
|
||||||
|
tp.paint(
|
||||||
|
canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ResolutionsMenu extends StatefulWidget {
|
class _ResolutionsMenu extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
final FFI ffi;
|
final FFI ffi;
|
||||||
@@ -1577,17 +1765,27 @@ class _KeyboardMenu extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var ffiModel = Provider.of<FfiModel>(context);
|
var ffiModel = Provider.of<FfiModel>(context);
|
||||||
if (!ffiModel.keyboard) return Offstage();
|
if (!ffiModel.keyboard) return Offstage();
|
||||||
toolbarToggles() => toolbarKeyboardToggles(ffi)
|
toolbarToggles() {
|
||||||
.map((e) => CkbMenuButton(
|
final toggles = toolbarKeyboardToggles(ffi)
|
||||||
value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi))
|
.map((e) => CkbMenuButton(
|
||||||
.toList();
|
value: e.value,
|
||||||
|
onChanged: e.onChanged,
|
||||||
|
child: e.child,
|
||||||
|
ffi: ffi) as Widget)
|
||||||
|
.toList();
|
||||||
|
if (toggles.isNotEmpty) {
|
||||||
|
toggles.add(Divider());
|
||||||
|
}
|
||||||
|
return toggles;
|
||||||
|
}
|
||||||
|
|
||||||
return _IconSubmenuButton(
|
return _IconSubmenuButton(
|
||||||
tooltip: 'Keyboard Settings',
|
tooltip: 'Keyboard Settings',
|
||||||
svg: "assets/keyboard.svg",
|
svg: "assets/keyboard_mouse.svg",
|
||||||
ffi: ffi,
|
ffi: ffi,
|
||||||
color: _ToolbarTheme.blueColor,
|
color: _ToolbarTheme.blueColor,
|
||||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||||
menuChildrenGetter: () => [
|
menuChildrenGetter: (_) => [
|
||||||
keyboardMode(),
|
keyboardMode(),
|
||||||
localKeyboardType(),
|
localKeyboardType(),
|
||||||
inputSource(),
|
inputSource(),
|
||||||
@@ -1663,8 +1861,18 @@ class _KeyboardMenu extends StatelessWidget {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pi.isWayland && mode.key != kKeyMapMode) {
|
if (pi.isWayland) {
|
||||||
continue;
|
// Legacy mode is hidden on desktop control side because dead keys
|
||||||
|
// don't work properly on Wayland. When the control side is mobile,
|
||||||
|
// Legacy mode is used automatically (mobile always sends Legacy events).
|
||||||
|
if (mode.key == kKeyLegacyMode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Translate mode requires server >= 1.4.6.
|
||||||
|
if (mode.key == kKeyTranslateMode &&
|
||||||
|
versionCmp(pi.version, '1.4.6') < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = translate(mode.menu);
|
var text = translate(mode.menu);
|
||||||
@@ -1852,7 +2060,7 @@ class _ChatMenuState extends State<_ChatMenu> {
|
|||||||
ffi: widget.ffi,
|
ffi: widget.ffi,
|
||||||
color: _ToolbarTheme.blueColor,
|
color: _ToolbarTheme.blueColor,
|
||||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||||
menuChildrenGetter: () => [textChat(), voiceCall()]);
|
menuChildrenGetter: (_) => [textChat(), voiceCall()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1908,7 +2116,7 @@ class _VoiceCallMenu extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
menuChildrenGetter() {
|
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||||
final audioInput = AudioInput(
|
final audioInput = AudioInput(
|
||||||
builder: (devices, currentDevice, setDevice) {
|
builder: (devices, currentDevice, setDevice) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -2014,7 +2222,12 @@ class _CloseMenu extends StatelessWidget {
|
|||||||
return _IconMenuButton(
|
return _IconMenuButton(
|
||||||
assetName: 'assets/close.svg',
|
assetName: 'assets/close.svg',
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
onPressed: () => closeConnection(id: id),
|
onPressed: () async {
|
||||||
|
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeConnection(id: id);
|
||||||
|
},
|
||||||
color: _ToolbarTheme.redColor,
|
color: _ToolbarTheme.redColor,
|
||||||
hoverColor: _ToolbarTheme.hoverRedColor,
|
hoverColor: _ToolbarTheme.hoverRedColor,
|
||||||
);
|
);
|
||||||
@@ -2108,7 +2321,7 @@ class _IconSubmenuButton extends StatefulWidget {
|
|||||||
final Widget? icon;
|
final Widget? icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
final Color hoverColor;
|
final Color hoverColor;
|
||||||
final List<Widget> Function() menuChildrenGetter;
|
final List<Widget> Function(_IconSubmenuButtonState state) menuChildrenGetter;
|
||||||
final MenuStyle? menuStyle;
|
final MenuStyle? menuStyle;
|
||||||
final FFI? ffi;
|
final FFI? ffi;
|
||||||
final double? width;
|
final double? width;
|
||||||
@@ -2133,6 +2346,11 @@ class _IconSubmenuButton extends StatefulWidget {
|
|||||||
class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
||||||
bool hover = false;
|
bool hover = false;
|
||||||
|
|
||||||
|
@override // discard @protected
|
||||||
|
void setState(VoidCallback fn) {
|
||||||
|
super.setState(fn);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
assert(widget.svg != null || widget.icon != null);
|
assert(widget.svg != null || widget.icon != null);
|
||||||
@@ -2165,7 +2383,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
|||||||
),
|
),
|
||||||
child: icon))),
|
child: icon))),
|
||||||
menuChildren: widget
|
menuChildren: widget
|
||||||
.menuChildrenGetter()
|
.menuChildrenGetter(this)
|
||||||
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
||||||
.toList()));
|
.toList()));
|
||||||
return MenuBar(children: [
|
return MenuBar(children: [
|
||||||
@@ -2266,6 +2484,8 @@ class RdoMenuButton<T> extends StatelessWidget {
|
|||||||
final ValueChanged<T?>? onChanged;
|
final ValueChanged<T?>? onChanged;
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
final FFI? ffi;
|
final FFI? ffi;
|
||||||
|
// When true, submenu will be dismissed on activate; when false, it stays open.
|
||||||
|
final bool closeOnActivate;
|
||||||
const RdoMenuButton({
|
const RdoMenuButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.value,
|
required this.value,
|
||||||
@@ -2273,6 +2493,7 @@ class RdoMenuButton<T> extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.ffi,
|
this.ffi,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.closeOnActivate = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2281,9 +2502,10 @@ class RdoMenuButton<T> extends StatelessWidget {
|
|||||||
value: value,
|
value: value,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
child: child,
|
child: child,
|
||||||
|
closeOnActivate: closeOnActivate,
|
||||||
onChanged: onChanged != null
|
onChanged: onChanged != null
|
||||||
? (T? value) {
|
? (T? value) {
|
||||||
if (ffi != null) {
|
if (ffi != null && closeOnActivate) {
|
||||||
_menuDismissCallback(ffi!);
|
_menuDismissCallback(ffi!);
|
||||||
}
|
}
|
||||||
onChanged?.call(value);
|
onChanged?.call(value);
|
||||||
@@ -2326,7 +2548,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
|||||||
double left = 0.0;
|
double left = 0.0;
|
||||||
double right = 1.0;
|
double right = 1.0;
|
||||||
|
|
||||||
RxBool get show => widget.toolbarState.show;
|
RxBool get collapse => widget.toolbarState.collapse;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
initState() {
|
initState() {
|
||||||
@@ -2449,20 +2671,20 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
|||||||
)),
|
)),
|
||||||
buttonWrapper(
|
buttonWrapper(
|
||||||
() => setState(() {
|
() => setState(() {
|
||||||
widget.toolbarState.switchShow(widget.sessionId);
|
widget.toolbarState.switchCollapse(widget.sessionId);
|
||||||
}),
|
}),
|
||||||
Obx((() => Tooltip(
|
Obx((() => Tooltip(
|
||||||
message:
|
message: translate(
|
||||||
translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
show.isTrue ? Icons.expand_less : Icons.expand_more,
|
collapse.isFalse ? Icons.expand_less : Icons.expand_more,
|
||||||
size: iconSize,
|
size: iconSize,
|
||||||
),
|
),
|
||||||
))),
|
))),
|
||||||
),
|
),
|
||||||
if (isWebDesktop)
|
if (isWebDesktop)
|
||||||
Obx(() {
|
Obx(() {
|
||||||
if (show.isTrue) {
|
if (collapse.isFalse) {
|
||||||
return Offstage();
|
return Offstage();
|
||||||
} else {
|
} else {
|
||||||
return buttonWrapper(
|
return buttonWrapper(
|
||||||
@@ -2524,3 +2746,56 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EdgeThicknessControl extends StatelessWidget {
|
||||||
|
final double value;
|
||||||
|
final ValueChanged<double>? onChanged;
|
||||||
|
final ColorScheme? colorScheme;
|
||||||
|
|
||||||
|
const EdgeThicknessControl({
|
||||||
|
Key? key,
|
||||||
|
required this.value,
|
||||||
|
this.onChanged,
|
||||||
|
this.colorScheme,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
static const double kMin = 20;
|
||||||
|
static const double kMax = 150;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final slider = SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
activeTrackColor: colorScheme.primary,
|
||||||
|
thumbColor: colorScheme.primary,
|
||||||
|
overlayColor: colorScheme.primary.withOpacity(0.1),
|
||||||
|
showValueIndicator: ShowValueIndicator.never,
|
||||||
|
thumbShape: _RectValueThumbShape(
|
||||||
|
min: EdgeThicknessControl.kMin,
|
||||||
|
max: EdgeThicknessControl.kMax,
|
||||||
|
width: 52,
|
||||||
|
height: 24,
|
||||||
|
radius: 4,
|
||||||
|
unit: 'px',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Semantics(
|
||||||
|
value: value.toInt().toString(),
|
||||||
|
child: Slider(
|
||||||
|
value: value,
|
||||||
|
min: EdgeThicknessControl.kMin,
|
||||||
|
max: EdgeThicknessControl.kMax,
|
||||||
|
divisions:
|
||||||
|
(EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(),
|
||||||
|
semanticFormatterCallback: (double newValue) =>
|
||||||
|
"${newValue.round()}px",
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return slider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -292,7 +292,6 @@ class DesktopTab extends StatefulWidget {
|
|||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class _DesktopTabState extends State<DesktopTab>
|
class _DesktopTabState extends State<DesktopTab>
|
||||||
with MultiWindowListener, WindowListener {
|
with MultiWindowListener, WindowListener {
|
||||||
final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1));
|
|
||||||
Timer? _macOSCheckRestoreTimer;
|
Timer? _macOSCheckRestoreTimer;
|
||||||
int _macOSCheckRestoreCounter = 0;
|
int _macOSCheckRestoreCounter = 0;
|
||||||
|
|
||||||
@@ -370,7 +369,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
|
|
||||||
void _setMaximized(bool maximize) {
|
void _setMaximized(bool maximize) {
|
||||||
stateGlobal.setMaximized(maximize);
|
stateGlobal.setMaximized(maximize);
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,24 +404,29 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
super.onWindowUnmaximize();
|
super.onWindowUnmaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveFrame() async {
|
_saveFrame({bool? flush}) async {
|
||||||
if (tabType == DesktopTabType.main) {
|
try {
|
||||||
await saveWindowPosition(WindowType.Main);
|
if (tabType == DesktopTabType.main) {
|
||||||
} else if (kWindowType != null && kWindowId != null) {
|
await saveWindowPosition(WindowType.Main, flush: flush);
|
||||||
await saveWindowPosition(kWindowType!, windowId: kWindowId);
|
} else if (kWindowType != null && kWindowId != null) {
|
||||||
|
await saveWindowPosition(kWindowType!,
|
||||||
|
windowId: kWindowId, flush: flush);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving window position: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowMoved() {
|
void onWindowMoved() {
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
super.onWindowMoved();
|
super.onWindowMoved();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowResized() {
|
void onWindowResized() {
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
super.onWindowMoved();
|
super.onWindowResized();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -460,6 +464,8 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _saveFrame(flush: true);
|
||||||
|
|
||||||
// hide window on close
|
// hide window on close
|
||||||
if (isMainWindow) {
|
if (isMainWindow) {
|
||||||
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
|
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
|
||||||
@@ -587,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
|
|
||||||
Widget _buildBar() {
|
Widget _buildBar() {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -1079,11 +1084,12 @@ class _TabState extends State<_Tab> with RestorationMixin {
|
|||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200),
|
constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: widget.tabType == DesktopTabType.main
|
message:
|
||||||
? ''
|
widget.tabType == DesktopTabType.main ? '' : widget.label.value,
|
||||||
: translate(widget.label.value),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
translate(widget.label.value),
|
widget.tabType == DesktopTabType.main
|
||||||
|
? translate(widget.label.value)
|
||||||
|
: widget.label.value,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
rdpUsername: '',
|
rdpUsername: '',
|
||||||
loginName: '',
|
loginName: '',
|
||||||
device_group_name: '',
|
device_group_name: '',
|
||||||
|
note: '',
|
||||||
);
|
);
|
||||||
_autocompleteOpts = [emptyPeer];
|
_autocompleteOpts = [emptyPeer];
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
|||||||
import 'package:flutter_hbb/models/file_model.dart';
|
import 'package:flutter_hbb/models/file_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:toggle_switch/toggle_switch.dart';
|
import 'package:toggle_switch/toggle_switch.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../common/widgets/dialog.dart';
|
import '../../common/widgets/dialog.dart';
|
||||||
|
|
||||||
class FileManagerPage extends StatefulWidget {
|
class FileManagerPage extends StatefulWidget {
|
||||||
FileManagerPage(
|
FileManagerPage(
|
||||||
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
|
{Key? key,
|
||||||
|
required this.id,
|
||||||
|
this.password,
|
||||||
|
this.isSharedPassword,
|
||||||
|
this.forceRelay})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
final String id;
|
final String id;
|
||||||
final String? password;
|
final String? password;
|
||||||
@@ -68,6 +71,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
showLocal ? model.localController : model.remoteController;
|
showLocal ? model.localController : model.remoteController;
|
||||||
FileDirectory get currentDir => currentFileController.directory.value;
|
FileDirectory get currentDir => currentFileController.directory.value;
|
||||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -82,7 +86,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
|
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
|
||||||
WakelockPlus.enable();
|
WakelockManager.enable(_uniqueKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,8 +94,9 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
model.close().whenComplete(() {
|
model.close().whenComplete(() {
|
||||||
gFFI.close();
|
gFFI.close();
|
||||||
gFFI.dialogManager.dismissAll();
|
gFFI.dialogManager.dismissAll();
|
||||||
WakelockPlus.disable();
|
WakelockManager.disable(_uniqueKey);
|
||||||
});
|
});
|
||||||
|
model.jobController.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +117,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
leading: Row(children: [
|
leading: Row(children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.close),
|
icon: Icon(Icons.close),
|
||||||
onPressed: () =>
|
onPressed: () => clientClose(gFFI.sessionId, gFFI)),
|
||||||
clientClose(gFFI.sessionId, gFFI.dialogManager)),
|
|
||||||
]),
|
]),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: ToggleSwitch(
|
title: ToggleSwitch(
|
||||||
@@ -351,15 +355,21 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
return Offstage();
|
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:
|
case JobState.inProgress:
|
||||||
return BottomSheetBody(
|
return BottomSheetBody(
|
||||||
leading: CircularProgressIndicator(),
|
leading: CircularProgressIndicator(),
|
||||||
title: translate("Waiting"),
|
title: translate("Waiting"),
|
||||||
text:
|
text:
|
||||||
"${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s",
|
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||||
onCanceled: () {
|
onCanceled: () {
|
||||||
model.jobController.cancelJob(jobTable.last.id);
|
model.jobController.cancelJob(activeJob.id);
|
||||||
jobTable.clear();
|
jobTable.clear();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -367,7 +377,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
|||||||
return BottomSheetBody(
|
return BottomSheetBody(
|
||||||
leading: Icon(Icons.check),
|
leading: Icon(Icons.check),
|
||||||
title: "${translate("Successful")}!",
|
title: "${translate("Successful")}!",
|
||||||
text: jobTable.last.display(),
|
text: activeJob.display(),
|
||||||
onCanceled: () => jobTable.clear(),
|
onCanceled: () => jobTable.clear(),
|
||||||
);
|
);
|
||||||
case JobState.error:
|
case JobState.error:
|
||||||
@@ -424,6 +434,7 @@ class FileManagerView extends StatefulWidget {
|
|||||||
class _FileManagerViewState extends State<FileManagerView> {
|
class _FileManagerViewState extends State<FileManagerView> {
|
||||||
final _listScrollController = ScrollController();
|
final _listScrollController = ScrollController();
|
||||||
final _breadCrumbScroller = ScrollController();
|
final _breadCrumbScroller = ScrollController();
|
||||||
|
late final ascending = Rx<bool>(controller.sortAscending);
|
||||||
|
|
||||||
bool get isLocal => widget.controller.isLocal;
|
bool get isLocal => widget.controller.isLocal;
|
||||||
FileController get controller => widget.controller;
|
FileController get controller => widget.controller;
|
||||||
@@ -635,7 +646,17 @@ class _FileManagerViewState extends State<FileManagerView> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
},
|
},
|
||||||
onSelected: controller.changeSortStyle),
|
onSelected: (sortBy) {
|
||||||
|
// If selecting the same sort option, flip the order
|
||||||
|
// If selecting a different sort option, use ascending order
|
||||||
|
if (controller.sortBy.value == sortBy) {
|
||||||
|
ascending.value = !controller.sortAscending;
|
||||||
|
} else {
|
||||||
|
ascending.value = true;
|
||||||
|
}
|
||||||
|
controller.changeSortStyle(sortBy,
|
||||||
|
ascending: ascending.value);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/mobile/widgets/floating_mouse.dart';
|
||||||
|
import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart';
|
||||||
import 'package:flutter_hbb/mobile/widgets/gesture_help.dart';
|
import 'package:flutter_hbb/mobile/widgets/gesture_help.dart';
|
||||||
import 'package:flutter_hbb/models/chat_model.dart';
|
import 'package:flutter_hbb/models/chat_model.dart';
|
||||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||||
import 'package:flutter_svg/svg.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../common/widgets/overlay.dart';
|
import '../../common/widgets/overlay.dart';
|
||||||
@@ -23,6 +24,7 @@ import '../../models/model.dart';
|
|||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
import '../widgets/dialog.dart';
|
import '../widgets/dialog.dart';
|
||||||
|
import '../widgets/custom_scale_widget.dart';
|
||||||
|
|
||||||
final initText = '1' * 1024;
|
final initText = '1' * 1024;
|
||||||
|
|
||||||
@@ -64,8 +66,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
String _value = '';
|
String _value = '';
|
||||||
Orientation? _currentOrientation;
|
Orientation? _currentOrientation;
|
||||||
double _viewInsetsBottom = 0;
|
double _viewInsetsBottom = 0;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
Timer? _timerDidChangeMetrics;
|
Timer? _timerDidChangeMetrics;
|
||||||
|
Timer? _iosKeyboardWorkaroundTimer;
|
||||||
|
|
||||||
final _blockableOverlayState = BlockableOverlayState();
|
final _blockableOverlayState = BlockableOverlayState();
|
||||||
|
|
||||||
@@ -102,9 +105,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
gFFI.dialogManager
|
gFFI.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isWeb) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
_physicalFocusNode.requestFocus();
|
_physicalFocusNode.requestFocus();
|
||||||
gFFI.inputModel.listenToMouse(true);
|
gFFI.inputModel.listenToMouse(true);
|
||||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||||
@@ -140,12 +141,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
await gFFI.close();
|
await gFFI.close();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timerDidChangeMetrics?.cancel();
|
_timerDidChangeMetrics?.cancel();
|
||||||
|
_iosKeyboardWorkaroundTimer?.cancel();
|
||||||
gFFI.dialogManager.dismissAll();
|
gFFI.dialogManager.dismissAll();
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
if (!isWeb) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
await keyboardSubscription.cancel();
|
await keyboardSubscription.cancel();
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
// `on_voice_call_closed` should be called when the connection is ended.
|
// `on_voice_call_closed` should be called when the connection is ended.
|
||||||
@@ -208,7 +208,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
gFFI.ffiModel.pi.version.isNotEmpty) {
|
gFFI.ffiModel.pi.version.isNotEmpty) {
|
||||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
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 {
|
} else {
|
||||||
|
_iosKeyboardWorkaroundTimer?.cancel();
|
||||||
|
_iosKeyboardWorkaroundTimer = null;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
|
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
@@ -363,7 +380,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, gFFI.dialogManager);
|
clientClose(sessionId, gFFI);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -481,7 +498,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
icon: Icon(Icons.clear),
|
icon: Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clientClose(sessionId, gFFI.dialogManager);
|
clientClose(sessionId, gFFI);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -566,7 +583,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get showCursorPaint =>
|
bool get showCursorPaint =>
|
||||||
!gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
|
!gFFI.ffiModel.isPeerAndroid &&
|
||||||
|
!gFFI.canvasModel.cursorEmbedded &&
|
||||||
|
!gFFI.inputModel.relativeMouseMode.value;
|
||||||
|
|
||||||
Widget getBodyForMobile() {
|
Widget getBodyForMobile() {
|
||||||
final keyboardIsVisible = keyboardVisibilityController.isVisible;
|
final keyboardIsVisible = keyboardVisibilityController.isVisible;
|
||||||
@@ -574,7 +593,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
color: MyTheme.canvasColor,
|
color: MyTheme.canvasColor,
|
||||||
child: Stack(children: () {
|
child: Stack(children: () {
|
||||||
final paints = [
|
final paints = [
|
||||||
ImagePaint(),
|
ImagePaint(ffiModel: gFFI.ffiModel),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: 10,
|
||||||
right: 10,
|
right: 10,
|
||||||
@@ -617,13 +636,22 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
if (showCursorPaint) {
|
if (showCursorPaint) {
|
||||||
paints.add(CursorPaint(widget.id));
|
paints.add(CursorPaint(widget.id));
|
||||||
}
|
}
|
||||||
|
if (gFFI.ffiModel.touchMode) {
|
||||||
|
paints.add(FloatingMouse(
|
||||||
|
ffi: gFFI,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
paints.add(FloatingMouseWidgets(
|
||||||
|
ffi: gFFI,
|
||||||
|
));
|
||||||
|
}
|
||||||
return paints;
|
return paints;
|
||||||
}()));
|
}()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget getBodyForDesktopWithListener() {
|
Widget getBodyForDesktopWithListener() {
|
||||||
final ffiModel = Provider.of<FfiModel>(context);
|
final ffiModel = Provider.of<FfiModel>(context);
|
||||||
var paints = <Widget>[ImagePaint()];
|
var paints = <Widget>[ImagePaint(ffiModel: ffiModel)];
|
||||||
if (showCursorPaint) {
|
if (showCursorPaint) {
|
||||||
final cursor = bind.sessionGetToggleOptionSync(
|
final cursor = bind.sessionGetToggleOptionSync(
|
||||||
sessionId: sessionId, arg: 'show-remote-cursor');
|
sessionId: sessionId, arg: 'show-remote-cursor');
|
||||||
@@ -789,13 +817,15 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
controller: ScrollController(),
|
controller: ScrollController(),
|
||||||
padding: EdgeInsets.symmetric(vertical: 10),
|
padding: EdgeInsets.symmetric(vertical: 10),
|
||||||
child: GestureHelp(
|
child: GestureHelp(
|
||||||
touchMode: gFFI.ffiModel.touchMode,
|
touchMode: gFFI.ffiModel.touchMode,
|
||||||
onTouchModeChange: (t) {
|
onTouchModeChange: (t) {
|
||||||
gFFI.ffiModel.toggleTouchMode();
|
gFFI.ffiModel.toggleTouchMode();
|
||||||
final v = gFFI.ffiModel.touchMode ? 'Y' : '';
|
final v = gFFI.ffiModel.touchMode ? 'Y' : 'N';
|
||||||
bind.sessionPeerOption(
|
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
|
||||||
sessionId: sessionId, name: kOptionTouchMode, value: v);
|
},
|
||||||
})));
|
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
|
||||||
|
inputModel: gFFI.inputModel,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Currently mobile does not enable map mode
|
// * Currently mobile does not enable map mode
|
||||||
@@ -1042,11 +1072,20 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ImagePaint extends StatelessWidget {
|
class ImagePaint extends StatelessWidget {
|
||||||
|
final FfiModel ffiModel;
|
||||||
|
ImagePaint({Key? key, required this.ffiModel}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final m = Provider.of<ImageModel>(context);
|
final m = Provider.of<ImageModel>(context);
|
||||||
final c = Provider.of<CanvasModel>(context);
|
final c = Provider.of<CanvasModel>(context);
|
||||||
var s = c.scale;
|
var s = c.scale;
|
||||||
|
if (ffiModel.isPeerLinux) {
|
||||||
|
final displays = ffiModel.pi.getCurDisplays();
|
||||||
|
if (displays.isNotEmpty) {
|
||||||
|
s = s / displays[0].scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
final adjust = c.getAdjustY();
|
final adjust = c.getAdjustY();
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
painter: ImagePainter(
|
painter: ImagePainter(
|
||||||
@@ -1117,6 +1156,14 @@ void showOptions(
|
|||||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||||
final cur = pi.currentDisplay;
|
final cur = pi.currentDisplay;
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
|
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||||
|
final numColorSelected = Colors.white;
|
||||||
|
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
|
||||||
|
// We can't use `Theme.of(context).primaryColor` here, the color is:
|
||||||
|
// - light theme: 0xff2196f3 (Colors.blue)
|
||||||
|
// - dark theme: 0xff212121 (the canvas color?)
|
||||||
|
final numBgSelected =
|
||||||
|
Theme.of(context).colorScheme.primary.withOpacity(0.6);
|
||||||
for (var i = 0; i < pi.displays.length; ++i) {
|
for (var i = 0; i < pi.displays.length; ++i) {
|
||||||
children.add(InkWell(
|
children.add(InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -1130,13 +1177,12 @@ void showOptions(
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Theme.of(context).hintColor),
|
border: Border.all(color: Theme.of(context).hintColor),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
color: i == cur
|
color: i == cur ? numBgSelected : null),
|
||||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
|
||||||
: null),
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text((i + 1).toString(),
|
child: Text((i + 1).toString(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: i == cur ? Colors.white : Colors.black87,
|
color:
|
||||||
|
i == cur ? numColorSelected : numColorUnselected,
|
||||||
fontWeight: FontWeight.bold))))));
|
fontWeight: FontWeight.bold))))));
|
||||||
}
|
}
|
||||||
displays.add(Padding(
|
displays.add(Padding(
|
||||||
@@ -1189,6 +1235,10 @@ void showOptions(
|
|||||||
if (v != null) viewStyle.value = v;
|
if (v != null) viewStyle.value = v;
|
||||||
}
|
}
|
||||||
: null)),
|
: null)),
|
||||||
|
// Show custom scale controls when custom view style is selected
|
||||||
|
Obx(() => viewStyle.value == kRemoteViewStyleCustom
|
||||||
|
? MobileCustomScaleControls(ffi: gFFI)
|
||||||
|
: const SizedBox.shrink()),
|
||||||
const Divider(color: MyTheme.border),
|
const Divider(color: MyTheme.border),
|
||||||
for (var e in imageQualityRadios)
|
for (var e in imageQualityRadios)
|
||||||
Obx(() => getRadio<String>(
|
Obx(() => getRadio<String>(
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class _ScanPageState extends State<ScanPage> {
|
|||||||
try {
|
try {
|
||||||
final sc = ServerConfig.decode(data.substring(7));
|
final sc = ServerConfig.decode(data.substring(7));
|
||||||
Timer(Duration(milliseconds: 60), () {
|
Timer(Duration(milliseconds: 60), () {
|
||||||
showServerSettingsWithValue(sc, gFFI.dialogManager);
|
showServerSettingsWithValue(sc, gFFI.dialogManager, null);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Invalid QR code');
|
showToast('Invalid QR code');
|
||||||
|
|||||||
@@ -61,12 +61,13 @@ class _DropDownAction extends StatelessWidget {
|
|||||||
final isAllowNumericOneTimePassword =
|
final isAllowNumericOneTimePassword =
|
||||||
gFFI.serverModel.allowNumericOneTimePassword;
|
gFFI.serverModel.allowNumericOneTimePassword;
|
||||||
return [
|
return [
|
||||||
PopupMenuItem(
|
if (!isChangeIdDisabled())
|
||||||
enabled: gFFI.serverModel.connectStatus > 0,
|
PopupMenuItem(
|
||||||
value: "changeID",
|
enabled: gFFI.serverModel.connectStatus > 0,
|
||||||
child: Text(translate("Change ID")),
|
value: "changeID",
|
||||||
),
|
child: Text(translate("Change ID")),
|
||||||
const PopupMenuDivider(),
|
),
|
||||||
|
if (!isChangeIdDisabled()) const PopupMenuDivider(),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'AcceptSessionsViaPassword',
|
value: 'AcceptSessionsViaPassword',
|
||||||
child: listTile(
|
child: listTile(
|
||||||
@@ -87,7 +88,8 @@ class _DropDownAction extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (showPasswordOption) const PopupMenuDivider(),
|
if (showPasswordOption) const PopupMenuDivider(),
|
||||||
if (showPasswordOption &&
|
if (showPasswordOption &&
|
||||||
verificationMethod != kUseTemporaryPassword)
|
verificationMethod != kUseTemporaryPassword &&
|
||||||
|
!isChangePermanentPasswordDisabled())
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: "setPermanentPassword",
|
value: "setPermanentPassword",
|
||||||
child: Text(translate("Set permanent password")),
|
child: Text(translate("Set permanent password")),
|
||||||
@@ -149,6 +151,10 @@ class _DropDownAction extends StatelessWidget {
|
|||||||
|
|
||||||
if (value == kUsePermanentPassword &&
|
if (value == kUsePermanentPassword &&
|
||||||
(await bind.mainGetPermanentPassword()).isEmpty) {
|
(await bind.mainGetPermanentPassword()).isEmpty) {
|
||||||
|
if (isChangePermanentPasswordDisabled()) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPasswordDialog(notEmptyCallback: callback);
|
setPasswordDialog(notEmptyCallback: callback);
|
||||||
} else {
|
} else {
|
||||||
callback();
|
callback();
|
||||||
@@ -648,9 +654,8 @@ class ConnectionManager extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: serverModel.clients
|
children: serverModel.clients
|
||||||
.map((client) => PaddingCard(
|
.map((client) => PaddingCard(
|
||||||
title: translate(client.isFileTransfer
|
title: translate(
|
||||||
? "Transfer file"
|
client.isFileTransfer ? "Transfer file" : "Share screen"),
|
||||||
: "Share screen"),
|
|
||||||
titleIcon: client.isFileTransfer
|
titleIcon: client.isFileTransfer
|
||||||
? Icon(Icons.folder_outlined)
|
? Icon(Icons.folder_outlined)
|
||||||
: Icon(Icons.mobile_screen_share),
|
: Icon(Icons.mobile_screen_share),
|
||||||
@@ -836,13 +841,7 @@ class ClientInfo extends StatelessWidget {
|
|||||||
flex: -1,
|
flex: -1,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: CircleAvatar(
|
child: _buildAvatar(context))),
|
||||||
backgroundColor: str2color(
|
|
||||||
client.name,
|
|
||||||
Theme.of(context).brightness == Brightness.light
|
|
||||||
? 255
|
|
||||||
: 150),
|
|
||||||
child: Text(client.name[0])))),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -855,6 +854,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() {
|
void androidChannelInit() {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
var _ignoreBatteryOpt = false;
|
var _ignoreBatteryOpt = false;
|
||||||
var _enableStartOnBoot = false;
|
var _enableStartOnBoot = false;
|
||||||
var _checkUpdateOnStartup = false;
|
var _checkUpdateOnStartup = false;
|
||||||
|
var _showTerminalExtraKeys = false;
|
||||||
var _floatingWindowDisabled = false;
|
var _floatingWindowDisabled = false;
|
||||||
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
|
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
|
||||||
var _enableAbr = false;
|
var _enableAbr = false;
|
||||||
@@ -94,7 +95,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
var _hideWebSocket = false;
|
var _hideWebSocket = false;
|
||||||
var _enableTrustedDevices = false;
|
var _enableTrustedDevices = false;
|
||||||
var _enableUdpPunch = false;
|
var _enableUdpPunch = false;
|
||||||
|
var _allowInsecureTlsFallback = false;
|
||||||
|
var _disableUdp = false;
|
||||||
var _enableIpv6Punch = false;
|
var _enableIpv6Punch = false;
|
||||||
|
var _isUsingPublicServer = false;
|
||||||
|
var _allowAskForNoteAtEndOfConnection = false;
|
||||||
|
var _preventSleepWhileConnected = true;
|
||||||
|
|
||||||
_SettingsState() {
|
_SettingsState() {
|
||||||
_enableAbr = option2bool(
|
_enableAbr = option2bool(
|
||||||
@@ -109,6 +115,9 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
|
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
|
||||||
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
|
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
|
||||||
_allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket);
|
_allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket);
|
||||||
|
_allowInsecureTlsFallback =
|
||||||
|
mainGetBoolOptionSync(kOptionAllowInsecureTLSFallback);
|
||||||
|
_disableUdp = bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
|
||||||
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
|
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
|
||||||
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
|
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
|
||||||
_autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing,
|
_autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing,
|
||||||
@@ -130,6 +139,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||||
|
_allowAskForNoteAtEndOfConnection =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||||
|
_preventSleepWhileConnected =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||||
|
_showTerminalExtraKeys =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -200,6 +215,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
update = true;
|
update = true;
|
||||||
_buildDate = buildDate;
|
_buildDate = buildDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||||
|
if (_isUsingPublicServer != isUsingPublicServer) {
|
||||||
|
update = true;
|
||||||
|
_isUsingPublicServer = isUsingPublicServer;
|
||||||
|
}
|
||||||
|
|
||||||
if (update) {
|
if (update) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
@@ -586,6 +608,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 {
|
onFloatingWindowChanged(bool toValue) async {
|
||||||
if (toValue) {
|
if (toValue) {
|
||||||
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
|
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
|
||||||
@@ -649,8 +688,18 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
|
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
|
||||||
? translate('Login')
|
? translate('Login')
|
||||||
: '${translate('Logout')} (${gFFI.userModel.userName.value})')),
|
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
|
||||||
leading: Icon(Icons.person),
|
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) {
|
onPressed: (context) {
|
||||||
if (gFFI.userModel.userName.value.isEmpty) {
|
if (gFFI.userModel.userName.value.isEmpty) {
|
||||||
loginDialog();
|
loginDialog();
|
||||||
@@ -667,9 +716,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
title: Text(translate('ID/Relay Server')),
|
title: Text(translate('ID/Relay Server')),
|
||||||
leading: Icon(Icons.cloud),
|
leading: Icon(Icons.cloud),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showServerSettings(gFFI.dialogManager);
|
showServerSettings(gFFI.dialogManager, (callback) async {
|
||||||
|
_isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||||
|
setState(callback);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
if (!isIOS && !_hideNetwork && !_hideProxy)
|
if (!_hideNetwork && !_hideProxy)
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: Text(translate('Socks5/Http(s) Proxy')),
|
title: Text(translate('Socks5/Http(s) Proxy')),
|
||||||
leading: Icon(Icons.network_ping),
|
leading: Icon(Icons.network_ping),
|
||||||
@@ -691,6 +743,38 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!_isUsingPublicServer)
|
||||||
|
SettingsTile.switchTile(
|
||||||
|
title: Text(translate('Allow insecure TLS fallback')),
|
||||||
|
initialValue: _allowInsecureTlsFallback,
|
||||||
|
onToggle: isOptionFixed(kOptionAllowInsecureTLSFallback)
|
||||||
|
? null
|
||||||
|
: (v) async {
|
||||||
|
await mainSetBoolOption(
|
||||||
|
kOptionAllowInsecureTLSFallback, v);
|
||||||
|
final newValue = mainGetBoolOptionSync(
|
||||||
|
kOptionAllowInsecureTLSFallback);
|
||||||
|
setState(() {
|
||||||
|
_allowInsecureTlsFallback = newValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (isAndroid && !outgoingOnly && !_isUsingPublicServer)
|
||||||
|
SettingsTile.switchTile(
|
||||||
|
title: Text(translate('Disable UDP')),
|
||||||
|
initialValue: _disableUdp,
|
||||||
|
onToggle: isOptionFixed(kOptionDisableUdp)
|
||||||
|
? null
|
||||||
|
: (v) async {
|
||||||
|
await bind.mainSetOption(
|
||||||
|
key: kOptionDisableUdp, value: v ? 'Y' : 'N');
|
||||||
|
final newValue =
|
||||||
|
bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
|
||||||
|
setState(() {
|
||||||
|
_disableUdp = newValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!incomingOnly)
|
if (!incomingOnly)
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: Text(translate('Enable UDP hole punching')),
|
title: Text(translate('Enable UDP hole punching')),
|
||||||
@@ -734,7 +818,38 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showThemeSettings(gFFI.dialogManager);
|
showThemeSettings(gFFI.dialogManager);
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
|
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)
|
if (isAndroid)
|
||||||
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [
|
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/models/model.dart';
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:xterm/xterm.dart';
|
import 'package:xterm/xterm.dart';
|
||||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||||
|
import '../../consts.dart';
|
||||||
|
|
||||||
class TerminalPage extends StatefulWidget {
|
class TerminalPage extends StatefulWidget {
|
||||||
const TerminalPage({
|
const TerminalPage({
|
||||||
@@ -28,9 +33,18 @@ class TerminalPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
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.
|
// For web only.
|
||||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||||
@@ -38,9 +52,12 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
|
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
|
||||||
: 'monospace';
|
: 'monospace';
|
||||||
|
|
||||||
|
SessionID get sessionId => _ffi.sessionId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||||
@@ -59,13 +76,25 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
'[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
|
// Register this terminal model with FFI for event routing
|
||||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
_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
|
// Initialize terminal connection
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_ffi.dialogManager
|
_ffi.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
|
|
||||||
|
if (_showTerminalExtraKeys) {
|
||||||
|
_updateKeyboardHeight();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||||
}
|
}
|
||||||
@@ -75,38 +104,325 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
// Unregister terminal model from FFI
|
// Unregister terminal model from FFI
|
||||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||||
_terminalModel.dispose();
|
_terminalModel.dispose();
|
||||||
|
_keyboardDebounce?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
TerminalConnectionManager.releaseConnection(widget.id);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
return Scaffold(
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
clientClose(sessionId, _ffi);
|
||||||
|
return false; // Prevent default back behavior
|
||||||
|
},
|
||||||
|
child: buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildBody() {
|
||||||
|
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,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: TerminalView(
|
body: Stack(
|
||||||
_terminalModel.terminal,
|
children: [
|
||||||
controller: _terminalModel.terminalController,
|
Positioned.fill(
|
||||||
autofocus: true,
|
child: SafeArea(
|
||||||
textStyle: _getTerminalStyle(),
|
top: true,
|
||||||
backgroundOpacity: 0.7,
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
builder: (context, constraints) {
|
||||||
onSecondaryTapDown: (details, offset) async {
|
final heightPx = constraints.maxHeight;
|
||||||
final selection = _terminalModel.terminalController.selection;
|
return TerminalView(
|
||||||
if (selection != null) {
|
_terminalModel.terminal,
|
||||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
controller: _terminalModel.terminalController,
|
||||||
_terminalModel.terminalController.clearSelection();
|
autofocus: true,
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
textStyle: _getTerminalStyle(),
|
||||||
} else {
|
backgroundOpacity: 0.7,
|
||||||
final data = await Clipboard.getData('text/plain');
|
// The following comment is from xterm.dart source code:
|
||||||
final text = data?.text;
|
// Workaround to detect delete key for platforms and IMEs that do not
|
||||||
if (text != null) {
|
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||||
_terminalModel.terminal.paste(text);
|
// 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
|
// 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:flutter_svg/svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../common/widgets/overlay.dart';
|
import '../../common/widgets/overlay.dart';
|
||||||
@@ -62,7 +61,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
bool _showGestureHelp = false;
|
bool _showGestureHelp = false;
|
||||||
Orientation? _currentOrientation;
|
Orientation? _currentOrientation;
|
||||||
double _viewInsetsBottom = 0;
|
double _viewInsetsBottom = 0;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
Timer? _timerDidChangeMetrics;
|
Timer? _timerDidChangeMetrics;
|
||||||
|
|
||||||
final _blockableOverlayState = BlockableOverlayState();
|
final _blockableOverlayState = BlockableOverlayState();
|
||||||
@@ -100,9 +99,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
gFFI.dialogManager
|
gFFI.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isWeb) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
_physicalFocusNode.requestFocus();
|
_physicalFocusNode.requestFocus();
|
||||||
gFFI.inputModel.listenToMouse(true);
|
gFFI.inputModel.listenToMouse(true);
|
||||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||||
@@ -139,9 +136,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
gFFI.dialogManager.dismissAll();
|
gFFI.dialogManager.dismissAll();
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
if (!isWeb) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
// `on_voice_call_closed` should be called when the connection is ended.
|
// `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.
|
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||||
@@ -197,7 +192,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
|
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, gFFI.dialogManager);
|
clientClose(sessionId, gFFI);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -310,7 +305,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
icon: Icon(Icons.clear),
|
icon: Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clientClose(sessionId, gFFI.dialogManager);
|
clientClose(sessionId, gFFI);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -590,6 +585,14 @@ void showOptions(
|
|||||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||||
final cur = pi.currentDisplay;
|
final cur = pi.currentDisplay;
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
|
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||||
|
final numColorSelected = Colors.white;
|
||||||
|
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
|
||||||
|
// We can't use `Theme.of(context).primaryColor` here, the color is:
|
||||||
|
// - light theme: 0xff2196f3 (Colors.blue)
|
||||||
|
// - dark theme: 0xff212121 (the canvas color?)
|
||||||
|
final numBgSelected =
|
||||||
|
Theme.of(context).colorScheme.primary.withOpacity(0.6);
|
||||||
for (var i = 0; i < pi.displays.length; ++i) {
|
for (var i = 0; i < pi.displays.length; ++i) {
|
||||||
children.add(InkWell(
|
children.add(InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -603,13 +606,12 @@ void showOptions(
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Theme.of(context).hintColor),
|
border: Border.all(color: Theme.of(context).hintColor),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
color: i == cur
|
color: i == cur ? numBgSelected : null),
|
||||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
|
||||||
: null),
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text((i + 1).toString(),
|
child: Text((i + 1).toString(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: i == cur ? Colors.white : Colors.black87,
|
color:
|
||||||
|
i == cur ? numColorSelected : numColorUnselected,
|
||||||
fontWeight: FontWeight.bold))))));
|
fontWeight: FontWeight.bold))))));
|
||||||
}
|
}
|
||||||
displays.add(Padding(
|
displays.add(Padding(
|
||||||
|
|||||||
71
flutter/lib/mobile/widgets/custom_scale_widget.dart
Normal file
71
flutter/lib/mobile/widgets/custom_scale_widget.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
||||||
|
|
||||||
|
class MobileCustomScaleControls extends StatefulWidget {
|
||||||
|
final FFI ffi;
|
||||||
|
final ValueChanged<int>? onChanged;
|
||||||
|
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
|
||||||
|
@override
|
||||||
|
FFI get ffi => widget.ffi;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Smaller button size for mobile
|
||||||
|
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
|
||||||
|
|
||||||
|
final sliderControl = Slider(
|
||||||
|
value: scalePos,
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
|
||||||
|
label: '$scaleValue%',
|
||||||
|
onChanged: onSliderChanged,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${translate("Scale custom")}: $scaleValue%',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
iconSize: 20,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: smallBtnConstraints,
|
||||||
|
icon: const Icon(Icons.remove),
|
||||||
|
tooltip: translate('Decrease'),
|
||||||
|
onPressed: () => nudgeScale(-1),
|
||||||
|
),
|
||||||
|
Expanded(child: sliderControl),
|
||||||
|
IconButton(
|
||||||
|
iconSize: 20,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: smallBtnConstraints,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: translate('Increase'),
|
||||||
|
onPressed: () => nudgeScale(1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,18 +147,22 @@ void setTemporaryPasswordLengthDialog(
|
|||||||
}, backDismiss: true, clickMaskDismiss: true);
|
}, backDismiss: true, clickMaskDismiss: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showServerSettings(OverlayDialogManager dialogManager) async {
|
void showServerSettings(OverlayDialogManager dialogManager,
|
||||||
|
void Function(VoidCallback) setState) async {
|
||||||
Map<String, dynamic> options = {};
|
Map<String, dynamic> options = {};
|
||||||
try {
|
try {
|
||||||
options = jsonDecode(await bind.mainGetOptions());
|
options = jsonDecode(await bind.mainGetOptions());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Invalid server config: $e");
|
print("Invalid server config: $e");
|
||||||
}
|
}
|
||||||
showServerSettingsWithValue(ServerConfig.fromOptions(options), dialogManager);
|
showServerSettingsWithValue(
|
||||||
|
ServerConfig.fromOptions(options), dialogManager, setState);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showServerSettingsWithValue(
|
void showServerSettingsWithValue(
|
||||||
ServerConfig serverConfig, OverlayDialogManager dialogManager) async {
|
ServerConfig serverConfig,
|
||||||
|
OverlayDialogManager dialogManager,
|
||||||
|
void Function(VoidCallback)? upSetState) async {
|
||||||
var isInProgress = false;
|
var isInProgress = false;
|
||||||
final idCtrl = TextEditingController(text: serverConfig.idServer);
|
final idCtrl = TextEditingController(text: serverConfig.idServer);
|
||||||
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
|
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
|
||||||
@@ -288,6 +292,7 @@ void showServerSettingsWithValue(
|
|||||||
if (await submit()) {
|
if (await submit()) {
|
||||||
close();
|
close();
|
||||||
showToast(translate('Successful'));
|
showToast(translate('Successful'));
|
||||||
|
upSetState?.call(() {});
|
||||||
} else {
|
} else {
|
||||||
showToast(translate('Failed'));
|
showToast(translate('Failed'));
|
||||||
}
|
}
|
||||||
|
|||||||
1209
flutter/lib/mobile/widgets/floating_mouse.dart
Normal file
1209
flutter/lib/mobile/widgets/floating_mouse.dart
Normal file
File diff suppressed because it is too large
Load Diff
905
flutter/lib/mobile/widgets/floating_mouse_widgets.dart
Normal file
905
flutter/lib/mobile/widgets/floating_mouse_widgets.dart
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
// These floating mouse widgets are used to simulate a physical mouse
|
||||||
|
// when "mobile" -> "desktop" in mouse mode.
|
||||||
|
// This file does not contain whole mouse widgets, it only contains
|
||||||
|
// parts that help to control, such as wheel scroll and wheel button.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||||
|
import 'package:flutter_hbb/models/input_model.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
|
||||||
|
// Used for the wheel button and wheel scroll widgets
|
||||||
|
const double _kSpaceToHorizontalEdge = 25;
|
||||||
|
const double _wheelWidth = 50;
|
||||||
|
const double _wheelHeight = 162;
|
||||||
|
// Used for the left/right button widgets
|
||||||
|
const double _kSpaceToVerticalEdge = 15;
|
||||||
|
const double _kSpaceBetweenLeftRightButtons = 40;
|
||||||
|
const double _kLeftRightButtonWidth = 55;
|
||||||
|
const double _kLeftRightButtonHeight = 40;
|
||||||
|
const double _kBorderWidth = 1;
|
||||||
|
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
|
||||||
|
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
|
||||||
|
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
|
||||||
|
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
|
||||||
|
const int _kInputTimerIntervalMillis = 100;
|
||||||
|
|
||||||
|
class FloatingMouseWidgets extends StatefulWidget {
|
||||||
|
final FFI ffi;
|
||||||
|
const FloatingMouseWidgets({
|
||||||
|
super.key,
|
||||||
|
required this.ffi,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
|
||||||
|
InputModel get _inputModel => widget.ffi.inputModel;
|
||||||
|
CursorModel get _cursorModel => widget.ffi.cursorModel;
|
||||||
|
late final VirtualMouseMode _virtualMouseMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
|
||||||
|
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
|
||||||
|
_cursorModel.blockEvents = false;
|
||||||
|
isSpecialHoldDragActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVirtualMouseModeChanged() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
|
||||||
|
super.dispose();
|
||||||
|
_cursorModel.blockEvents = false;
|
||||||
|
isSpecialHoldDragActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final virtualMouseMode = _virtualMouseMode;
|
||||||
|
if (!virtualMouseMode.showVirtualMouse) {
|
||||||
|
return const Offstage();
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
FloatingWheel(
|
||||||
|
inputModel: _inputModel,
|
||||||
|
cursorModel: _cursorModel,
|
||||||
|
),
|
||||||
|
if (virtualMouseMode.showVirtualJoystick)
|
||||||
|
VirtualJoystick(
|
||||||
|
cursorModel: _cursorModel,
|
||||||
|
inputModel: _inputModel,
|
||||||
|
),
|
||||||
|
FloatingLeftRightButton(
|
||||||
|
isLeft: true,
|
||||||
|
inputModel: _inputModel,
|
||||||
|
cursorModel: _cursorModel,
|
||||||
|
),
|
||||||
|
FloatingLeftRightButton(
|
||||||
|
isLeft: false,
|
||||||
|
inputModel: _inputModel,
|
||||||
|
cursorModel: _cursorModel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FloatingWheel extends StatefulWidget {
|
||||||
|
final InputModel inputModel;
|
||||||
|
final CursorModel cursorModel;
|
||||||
|
const FloatingWheel(
|
||||||
|
{super.key, required this.inputModel, required this.cursorModel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingWheel> createState() => _FloatingWheelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingWheelState extends State<FloatingWheel> {
|
||||||
|
Offset _position = Offset.zero;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
Rect? _lastBlockedRect;
|
||||||
|
|
||||||
|
bool _isUpDown = false;
|
||||||
|
bool _isMidDown = false;
|
||||||
|
bool _isDownDown = false;
|
||||||
|
|
||||||
|
Orientation? _previousOrientation;
|
||||||
|
|
||||||
|
Timer? _scrollTimer;
|
||||||
|
|
||||||
|
InputModel get _inputModel => widget.inputModel;
|
||||||
|
CursorModel get _cursorModel => widget.cursorModel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_resetPosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetPosition() {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
setState(() {
|
||||||
|
_position = Offset(
|
||||||
|
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
|
||||||
|
(size.height - _wheelHeight) / 2,
|
||||||
|
);
|
||||||
|
_isInitialized = true;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _updateBlockedRect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateBlockedRect() {
|
||||||
|
if (_lastBlockedRect != null) {
|
||||||
|
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||||
|
}
|
||||||
|
final newRect =
|
||||||
|
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
|
||||||
|
_cursorModel.addBlockedRect(newRect);
|
||||||
|
_lastBlockedRect = newRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollTimer?.cancel();
|
||||||
|
if (_lastBlockedRect != null) {
|
||||||
|
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final currentOrientation = MediaQuery.of(context).orientation;
|
||||||
|
if (_previousOrientation != null &&
|
||||||
|
_previousOrientation != currentOrientation) {
|
||||||
|
_resetPosition();
|
||||||
|
}
|
||||||
|
_previousOrientation = currentOrientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUpDownButton(
|
||||||
|
void Function(PointerDownEvent) onPointerDown,
|
||||||
|
void Function(PointerUpEvent) onPointerUp,
|
||||||
|
void Function(PointerCancelEvent) onPointerCancel,
|
||||||
|
bool Function() flagGetter,
|
||||||
|
BorderRadiusGeometry borderRadius,
|
||||||
|
IconData iconData) {
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: onPointerDown,
|
||||||
|
onPointerUp: onPointerUp,
|
||||||
|
onPointerCancel: onPointerCancel,
|
||||||
|
child: Container(
|
||||||
|
width: _wheelWidth,
|
||||||
|
height: 55,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kDefaultColor,
|
||||||
|
border: Border.all(
|
||||||
|
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
|
||||||
|
width: 1),
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
),
|
||||||
|
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
return Positioned(child: Offstage());
|
||||||
|
}
|
||||||
|
return Positioned(
|
||||||
|
left: _position.dx,
|
||||||
|
top: _position.dy,
|
||||||
|
child: _buildWidget(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWidget(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: _wheelWidth,
|
||||||
|
height: _wheelHeight,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildUpDownButton(
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isUpDown = true;
|
||||||
|
});
|
||||||
|
_startScrollTimer(1);
|
||||||
|
},
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isUpDown = false;
|
||||||
|
});
|
||||||
|
_stopScrollTimer();
|
||||||
|
},
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isUpDown = false;
|
||||||
|
});
|
||||||
|
_stopScrollTimer();
|
||||||
|
},
|
||||||
|
() => _isUpDown,
|
||||||
|
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
|
||||||
|
Icons.keyboard_arrow_up,
|
||||||
|
),
|
||||||
|
Listener(
|
||||||
|
onPointerDown: (event) {
|
||||||
|
setState(() {
|
||||||
|
_isMidDown = true;
|
||||||
|
});
|
||||||
|
_inputModel.tapDown(MouseButtons.wheel);
|
||||||
|
},
|
||||||
|
onPointerUp: (event) {
|
||||||
|
setState(() {
|
||||||
|
_isMidDown = false;
|
||||||
|
});
|
||||||
|
_inputModel.tapUp(MouseButtons.wheel);
|
||||||
|
},
|
||||||
|
onPointerCancel: (event) {
|
||||||
|
setState(() {
|
||||||
|
_isMidDown = false;
|
||||||
|
});
|
||||||
|
_inputModel.tapUp(MouseButtons.wheel);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: _wheelWidth,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kDefaultColor,
|
||||||
|
border: Border.symmetric(
|
||||||
|
vertical: BorderSide(
|
||||||
|
color:
|
||||||
|
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||||
|
width: _kBorderWidth)),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: _wheelWidth - 10,
|
||||||
|
height: _wheelWidth - 10,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 2,
|
||||||
|
color: _kDefaultBorderColor,
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 2,
|
||||||
|
color: _kDefaultBorderColor,
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 2,
|
||||||
|
color: _kDefaultBorderColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildUpDownButton(
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isDownDown = true;
|
||||||
|
});
|
||||||
|
_startScrollTimer(-1);
|
||||||
|
},
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isDownDown = false;
|
||||||
|
});
|
||||||
|
_stopScrollTimer();
|
||||||
|
},
|
||||||
|
(event) {
|
||||||
|
setState(() {
|
||||||
|
_isDownDown = false;
|
||||||
|
});
|
||||||
|
_stopScrollTimer();
|
||||||
|
},
|
||||||
|
() => _isDownDown,
|
||||||
|
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
|
||||||
|
Icons.keyboard_arrow_down,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startScrollTimer(int direction) {
|
||||||
|
_scrollTimer?.cancel();
|
||||||
|
_inputModel.scroll(direction);
|
||||||
|
_scrollTimer = Timer.periodic(
|
||||||
|
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
|
||||||
|
_inputModel.scroll(direction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopScrollTimer() {
|
||||||
|
_scrollTimer?.cancel();
|
||||||
|
_scrollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FloatingLeftRightButton extends StatefulWidget {
|
||||||
|
final bool isLeft;
|
||||||
|
final InputModel inputModel;
|
||||||
|
final CursorModel cursorModel;
|
||||||
|
const FloatingLeftRightButton(
|
||||||
|
{super.key,
|
||||||
|
required this.isLeft,
|
||||||
|
required this.inputModel,
|
||||||
|
required this.cursorModel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingLeftRightButton> createState() =>
|
||||||
|
_FloatingLeftRightButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
|
||||||
|
Offset _position = Offset.zero;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isDown = false;
|
||||||
|
Rect? _lastBlockedRect;
|
||||||
|
|
||||||
|
Orientation? _previousOrientation;
|
||||||
|
Offset _preSavedPos = Offset.zero;
|
||||||
|
|
||||||
|
// Gesture ambiguity resolution
|
||||||
|
Timer? _tapDownTimer;
|
||||||
|
final Duration _pressTimeout = const Duration(milliseconds: 200);
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
bool get _isLeft => widget.isLeft;
|
||||||
|
InputModel get _inputModel => widget.inputModel;
|
||||||
|
CursorModel get _cursorModel => widget.cursorModel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final currentOrientation = MediaQuery.of(context).orientation;
|
||||||
|
_previousOrientation = currentOrientation;
|
||||||
|
_resetPosition(currentOrientation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_lastBlockedRect != null) {
|
||||||
|
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||||
|
}
|
||||||
|
_tapDownTimer?.cancel();
|
||||||
|
_trySavePosition();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final currentOrientation = MediaQuery.of(context).orientation;
|
||||||
|
if (_previousOrientation == null ||
|
||||||
|
_previousOrientation != currentOrientation) {
|
||||||
|
_resetPosition(currentOrientation);
|
||||||
|
}
|
||||||
|
_previousOrientation = currentOrientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getOffsetX(double w) {
|
||||||
|
if (_isLeft) {
|
||||||
|
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
|
||||||
|
0.5;
|
||||||
|
} else {
|
||||||
|
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPositionKey(Orientation ori) {
|
||||||
|
final strLeftRight = _isLeft ? 'l' : 'r';
|
||||||
|
final strOri = ori == Orientation.landscape ? 'l' : 'p';
|
||||||
|
return '$strLeftRight$strOri-mouse-btn-pos';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Offset? _loadPositionFromString(String s) {
|
||||||
|
if (s.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final m = jsonDecode(s);
|
||||||
|
return Offset(m['x'], m['y']);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrintStack(label: 'Failed to load position "$s" $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trySavePosition() {
|
||||||
|
if (_previousOrientation == null) return;
|
||||||
|
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
|
||||||
|
final pos = jsonEncode({
|
||||||
|
'x': _position.dx,
|
||||||
|
'y': _position.dy,
|
||||||
|
});
|
||||||
|
bind.setLocalFlutterOption(
|
||||||
|
k: _getPositionKey(_previousOrientation!), v: pos);
|
||||||
|
_preSavedPos = _position;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restorePosition(Orientation ori) {
|
||||||
|
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
|
||||||
|
final pos = _loadPositionFromString(ps);
|
||||||
|
if (pos == null) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
_position = Offset(_getOffsetX(size.width),
|
||||||
|
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
|
||||||
|
} else {
|
||||||
|
_position = pos;
|
||||||
|
_preSavedPos = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetPosition(Orientation ori) {
|
||||||
|
setState(() {
|
||||||
|
_restorePosition(ori);
|
||||||
|
_isInitialized = true;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _updateBlockedRect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateBlockedRect() {
|
||||||
|
if (_lastBlockedRect != null) {
|
||||||
|
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||||
|
}
|
||||||
|
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
|
||||||
|
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
|
||||||
|
_cursorModel.addBlockedRect(newRect);
|
||||||
|
_lastBlockedRect = newRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMoveUpdateDelta(Offset delta) {
|
||||||
|
final context = this.context;
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
Offset newPosition = _position + delta;
|
||||||
|
double minX = _kSpaceToHorizontalEdge;
|
||||||
|
double minY = _kSpaceToVerticalEdge;
|
||||||
|
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
|
||||||
|
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
|
||||||
|
newPosition = Offset(
|
||||||
|
newPosition.dx.clamp(minX, maxX),
|
||||||
|
newPosition.dy.clamp(minY, maxY),
|
||||||
|
);
|
||||||
|
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
|
||||||
|
isDoubleEqual(newPosition.dy, _position.dy));
|
||||||
|
setState(() {
|
||||||
|
_position = newPosition;
|
||||||
|
});
|
||||||
|
if (isPositionChanged) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _updateBlockedRect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
|
||||||
|
_cursorModel.blockEvents = true;
|
||||||
|
// If move, it's a drag, not a tap.
|
||||||
|
_isDragging = true;
|
||||||
|
// Cancel the timer to prevent it from being recognized as a tap/hold.
|
||||||
|
_tapDownTimer?.cancel();
|
||||||
|
_tapDownTimer = null;
|
||||||
|
_onMoveUpdateDelta(event.delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildButtonIcon() {
|
||||||
|
final double w = _kLeftRightButtonWidth * 0.45;
|
||||||
|
final double h = _kLeftRightButtonHeight * 0.75;
|
||||||
|
final double borderRadius = w * 0.5;
|
||||||
|
final double quarterCircleRadius = borderRadius * 0.9;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: _isLeft ? quarterCircleRadius * 0.25 : null,
|
||||||
|
right: _isLeft ? null : quarterCircleRadius * 0.25,
|
||||||
|
top: quarterCircleRadius * 0.25,
|
||||||
|
child: CustomPaint(
|
||||||
|
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
|
||||||
|
painter: _QuarterCirclePainter(
|
||||||
|
color: _kDefaultColor,
|
||||||
|
isLeft: _isLeft,
|
||||||
|
radius: quarterCircleRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
return Positioned(child: Offstage());
|
||||||
|
}
|
||||||
|
return Positioned(
|
||||||
|
left: _position.dx,
|
||||||
|
top: _position.dy,
|
||||||
|
// We can't use the GestureDetector here, because `onTapDown` may be
|
||||||
|
// triggered sometimes when dragging.
|
||||||
|
child: Listener(
|
||||||
|
onPointerMove: _onBodyPointerMoveUpdate,
|
||||||
|
onPointerDown: (event) async {
|
||||||
|
_isDragging = false;
|
||||||
|
setState(() {
|
||||||
|
_isDown = true;
|
||||||
|
});
|
||||||
|
// Start a timer. If it fires, it's a hold.
|
||||||
|
_tapDownTimer?.cancel();
|
||||||
|
_tapDownTimer = Timer(_pressTimeout, () {
|
||||||
|
isSpecialHoldDragActive = true;
|
||||||
|
() async {
|
||||||
|
await _cursorModel.syncCursorPosition();
|
||||||
|
await _inputModel
|
||||||
|
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||||
|
}();
|
||||||
|
_tapDownTimer = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPointerUp: (event) {
|
||||||
|
_cursorModel.blockEvents = false;
|
||||||
|
setState(() {
|
||||||
|
_isDown = false;
|
||||||
|
});
|
||||||
|
// If timer is active, it's a quick tap.
|
||||||
|
if (_tapDownTimer != null) {
|
||||||
|
_tapDownTimer!.cancel();
|
||||||
|
_tapDownTimer = null;
|
||||||
|
// Fire tap down and up quickly.
|
||||||
|
_inputModel
|
||||||
|
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
|
||||||
|
.then(
|
||||||
|
(_) => Future.delayed(const Duration(milliseconds: 50), () {
|
||||||
|
_inputModel.tapUp(
|
||||||
|
_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// If it's not a quick tap, it could be a hold or drag.
|
||||||
|
// If it was a hold, isSpecialHoldDragActive is true.
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
_inputModel
|
||||||
|
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isDragging) {
|
||||||
|
_trySavePosition();
|
||||||
|
}
|
||||||
|
isSpecialHoldDragActive = false;
|
||||||
|
},
|
||||||
|
onPointerCancel: (event) {
|
||||||
|
_cursorModel.blockEvents = false;
|
||||||
|
setState(() {
|
||||||
|
_isDown = false;
|
||||||
|
});
|
||||||
|
_tapDownTimer?.cancel();
|
||||||
|
_tapDownTimer = null;
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||||
|
}
|
||||||
|
isSpecialHoldDragActive = false;
|
||||||
|
if (_isDragging) {
|
||||||
|
_trySavePosition();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: _kLeftRightButtonWidth,
|
||||||
|
height: _kLeftRightButtonHeight,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kDefaultColor,
|
||||||
|
border: Border.all(
|
||||||
|
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||||
|
width: _kBorderWidth),
|
||||||
|
borderRadius: _isLeft
|
||||||
|
? BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
|
||||||
|
: BorderRadius.horizontal(
|
||||||
|
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
|
||||||
|
),
|
||||||
|
child: _buildButtonIcon(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuarterCirclePainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
final bool isLeft;
|
||||||
|
final double radius;
|
||||||
|
_QuarterCirclePainter(
|
||||||
|
{required this.color, required this.isLeft, required this.radius});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
|
||||||
|
if (isLeft) {
|
||||||
|
canvas.drawArc(rect, -pi, pi / 2, true, paint);
|
||||||
|
} else {
|
||||||
|
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
required this.inputModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VirtualJoystick> createState() => _VirtualJoystickState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||||
|
Offset _position = Offset.zero;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
Offset _offset = Offset.zero;
|
||||||
|
final double _joystickRadius = 50.0;
|
||||||
|
final double _thumbRadius = 20.0;
|
||||||
|
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
|
||||||
|
Timer? _continuousMoveTimer;
|
||||||
|
Size? _lastScreenSize;
|
||||||
|
bool _isPressed = false;
|
||||||
|
|
||||||
|
/// Check if relative mouse mode is enabled.
|
||||||
|
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.cursorModel.blockEvents = false;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_lastScreenSize = MediaQuery.of(context).size;
|
||||||
|
_resetPosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stopSendEventTimer();
|
||||||
|
widget.cursorModel.blockEvents = false;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final currentScreenSize = MediaQuery.of(context).size;
|
||||||
|
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
|
||||||
|
_resetPosition();
|
||||||
|
}
|
||||||
|
_lastScreenSize = currentScreenSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetPosition() {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
setState(() {
|
||||||
|
_position = Offset(
|
||||||
|
_kSpaceToHorizontalEdge + _joystickRadius,
|
||||||
|
size.height * 0.5 + _joystickRadius * 1.5,
|
||||||
|
);
|
||||||
|
_isInitialized = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _offsetToPanDelta(Offset offset) {
|
||||||
|
return Offset(
|
||||||
|
offset.dx / _joystickRadius,
|
||||||
|
offset.dy / _joystickRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
_dragStartTimer = null;
|
||||||
|
_continuousMoveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
return Positioned(child: Offstage());
|
||||||
|
}
|
||||||
|
return Positioned(
|
||||||
|
left: _position.dx - _joystickRadius,
|
||||||
|
top: _position.dy - _joystickRadius,
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanStart: (details) {
|
||||||
|
setState(() {
|
||||||
|
_isPressed = true;
|
||||||
|
});
|
||||||
|
widget.cursorModel.blockEvents = true;
|
||||||
|
_updateOffset(details.localPosition);
|
||||||
|
|
||||||
|
// 1. Send a single, small pan event immediately for responsiveness.
|
||||||
|
// The movement is small for a gentle start.
|
||||||
|
final initialDelta = _offsetToPanDelta(_offset);
|
||||||
|
if (initialDelta.distance > 0) {
|
||||||
|
_sendMovement(initialDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Start a one-shot timer to check if the user is holding for a drag.
|
||||||
|
_dragStartTimer?.cancel();
|
||||||
|
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
|
||||||
|
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
|
||||||
|
_continuousMoveTimer?.cancel();
|
||||||
|
_continuousMoveTimer =
|
||||||
|
periodic_immediate(const Duration(milliseconds: 20), () async {
|
||||||
|
if (_offset != Offset.zero) {
|
||||||
|
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanUpdate: (details) {
|
||||||
|
_updateOffset(details.localPosition);
|
||||||
|
},
|
||||||
|
onPanEnd: (details) {
|
||||||
|
setState(() {
|
||||||
|
_offset = Offset.zero;
|
||||||
|
_isPressed = false;
|
||||||
|
});
|
||||||
|
widget.cursorModel.blockEvents = false;
|
||||||
|
|
||||||
|
// 4. Critical step: On pan end, cancel all timers.
|
||||||
|
// If it was a flick, this cancels the drag detection before it fires.
|
||||||
|
// If it was a drag, this stops the continuous movement.
|
||||||
|
_stopSendEventTimer();
|
||||||
|
},
|
||||||
|
child: CustomPaint(
|
||||||
|
size: Size(_joystickRadius * 2, _joystickRadius * 2),
|
||||||
|
painter: _JoystickPainter(
|
||||||
|
_offset, _joystickRadius, _thumbRadius, _isPressed),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateOffset(Offset localPosition) {
|
||||||
|
final center = Offset(_joystickRadius, _joystickRadius);
|
||||||
|
final offset = localPosition - center;
|
||||||
|
final distance = offset.distance;
|
||||||
|
|
||||||
|
if (distance <= _joystickRadius) {
|
||||||
|
setState(() {
|
||||||
|
_offset = offset;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
final clampedOffset = offset / distance * _joystickRadius;
|
||||||
|
setState(() {
|
||||||
|
_offset = clampedOffset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _JoystickPainter extends CustomPainter {
|
||||||
|
final Offset _offset;
|
||||||
|
final double _joystickRadius;
|
||||||
|
final double _thumbRadius;
|
||||||
|
final bool _isPressed;
|
||||||
|
|
||||||
|
_JoystickPainter(
|
||||||
|
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final joystickColor = _kDefaultColor;
|
||||||
|
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
|
||||||
|
final thumbColor = _kWidgetHighlightColor;
|
||||||
|
|
||||||
|
final joystickPaint = Paint()
|
||||||
|
..color = joystickColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final borderPaint = Paint()
|
||||||
|
..color = borderColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5;
|
||||||
|
|
||||||
|
final thumbPaint = Paint()
|
||||||
|
..color = thumbColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
// Draw joystick base and border
|
||||||
|
canvas.drawCircle(center, _joystickRadius, joystickPaint);
|
||||||
|
canvas.drawCircle(center, _joystickRadius, borderPaint);
|
||||||
|
|
||||||
|
// Draw thumb
|
||||||
|
final thumbCenter = center + _offset;
|
||||||
|
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
|
||||||
|
return oldDelegate._offset != _offset ||
|
||||||
|
oldDelegate._isPressed != _isPressed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common.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';
|
import 'package:toggle_switch/toggle_switch.dart';
|
||||||
|
|
||||||
class GestureIcons {
|
class GestureIcons {
|
||||||
@@ -35,24 +38,41 @@ typedef OnTouchModeChange = void Function(bool);
|
|||||||
|
|
||||||
class GestureHelp extends StatefulWidget {
|
class GestureHelp extends StatefulWidget {
|
||||||
GestureHelp(
|
GestureHelp(
|
||||||
{Key? key, required this.touchMode, required this.onTouchModeChange})
|
{Key? key,
|
||||||
|
required this.touchMode,
|
||||||
|
required this.onTouchModeChange,
|
||||||
|
required this.virtualMouseMode,
|
||||||
|
this.inputModel})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
final bool touchMode;
|
final bool touchMode;
|
||||||
final OnTouchModeChange onTouchModeChange;
|
final OnTouchModeChange onTouchModeChange;
|
||||||
|
final VirtualMouseMode virtualMouseMode;
|
||||||
|
final InputModel? inputModel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _GestureHelpState(touchMode);
|
State<StatefulWidget> createState() =>
|
||||||
|
_GestureHelpState(touchMode, virtualMouseMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GestureHelpState extends State<GestureHelp> {
|
class _GestureHelpState extends State<GestureHelp> {
|
||||||
late int _selectedIndex;
|
late int _selectedIndex;
|
||||||
late bool _touchMode;
|
late bool _touchMode;
|
||||||
|
final VirtualMouseMode _virtualMouseMode;
|
||||||
|
|
||||||
_GestureHelpState(bool touchMode) {
|
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
|
||||||
|
: _virtualMouseMode = virtualMouseMode {
|
||||||
_touchMode = touchMode;
|
_touchMode = touchMode;
|
||||||
_selectedIndex = _touchMode ? 1 : 0;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
@@ -68,31 +88,193 @@ class _GestureHelpState extends State<GestureHelp> {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ToggleSwitch(
|
Center(
|
||||||
initialLabelIndex: _selectedIndex,
|
child: Column(
|
||||||
activeFgColor: Colors.white,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
inactiveFgColor: Colors.white60,
|
children: [
|
||||||
activeBgColor: [MyTheme.accent],
|
ToggleSwitch(
|
||||||
inactiveBgColor: Theme.of(context).hintColor,
|
initialLabelIndex: _selectedIndex,
|
||||||
totalSwitches: 2,
|
activeFgColor: Colors.white,
|
||||||
minWidth: 150,
|
inactiveFgColor: Colors.white60,
|
||||||
fontSize: 15,
|
activeBgColor: [MyTheme.accent],
|
||||||
iconSize: 18,
|
inactiveBgColor: Theme.of(context).hintColor,
|
||||||
labels: [translate("Mouse mode"), translate("Touch mode")],
|
totalSwitches: 2,
|
||||||
icons: [Icons.mouse, Icons.touch_app],
|
minWidth: 150,
|
||||||
onToggle: (index) {
|
fontSize: 15,
|
||||||
setState(() {
|
iconSize: 18,
|
||||||
if (_selectedIndex != index) {
|
labels: [
|
||||||
_selectedIndex = index ?? 0;
|
translate("Mouse mode"),
|
||||||
_touchMode = index == 0 ? false : true;
|
translate("Touch mode")
|
||||||
widget.onTouchModeChange(_touchMode);
|
],
|
||||||
}
|
icons: [Icons.mouse, Icons.touch_app],
|
||||||
});
|
onToggle: (index) {
|
||||||
},
|
setState(() {
|
||||||
|
if (_selectedIndex != index) {
|
||||||
|
_selectedIndex = index ?? 0;
|
||||||
|
_touchMode = index == 0 ? false : true;
|
||||||
|
widget.onTouchModeChange(_touchMode);
|
||||||
|
// Exit relative mouse mode when switching to touch mode
|
||||||
|
_exitRelativeMouseModeIf(_touchMode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(-10.0, 0.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _virtualMouseMode.showVirtualMouse,
|
||||||
|
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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||||
|
Padding(
|
||||||
|
// Indent "Virtual mouse size"
|
||||||
|
padding: const EdgeInsets.only(left: 24.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 0.0, bottom: 0),
|
||||||
|
child: Text(translate('Virtual mouse size')),
|
||||||
|
),
|
||||||
|
Transform.translate(
|
||||||
|
offset: Offset(-0.0, -6.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(left: 0.0),
|
||||||
|
child: Text(translate('Small')),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _virtualMouseMode
|
||||||
|
.virtualMouseScale,
|
||||||
|
min: 0.8,
|
||||||
|
max: 1.8,
|
||||||
|
divisions: 10,
|
||||||
|
onChanged: (value) {
|
||||||
|
_virtualMouseMode
|
||||||
|
.setVirtualMouseScale(value);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(right: 16.0),
|
||||||
|
child: Text(translate('Large')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(-10.0, -12.0),
|
||||||
|
child: Padding(
|
||||||
|
// Indent "Show virtual joystick"
|
||||||
|
padding: const EdgeInsets.only(left: 24.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value:
|
||||||
|
_virtualMouseMode.showVirtualJoystick,
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value == null) return;
|
||||||
|
await _virtualMouseMode
|
||||||
|
.toggleVirtualJoystick();
|
||||||
|
// Exit relative mouse mode when joystick is hidden
|
||||||
|
_exitRelativeMouseModeIf(
|
||||||
|
!_virtualMouseMode
|
||||||
|
.showVirtualJoystick);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
await _virtualMouseMode
|
||||||
|
.toggleVirtualJoystick();
|
||||||
|
// Exit relative mouse mode when joystick is hidden
|
||||||
|
_exitRelativeMouseModeIf(
|
||||||
|
!_virtualMouseMode
|
||||||
|
.showVirtualJoystick);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
translate("Show virtual joystick")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
// 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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
|
||||||
Container(
|
Container(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: space,
|
spacing: space,
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class AbModel {
|
|||||||
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
|
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
|
||||||
var headers = getHttpHeaders();
|
var headers = getHttpHeaders();
|
||||||
headers['Content-Type'] = "application/json";
|
headers['Content-Type'] = "application/json";
|
||||||
|
_setEmptyBody(headers);
|
||||||
final resp = await http.post(Uri.parse(api), headers: headers);
|
final resp = await http.post(Uri.parse(api), headers: headers);
|
||||||
if (resp.statusCode == 404) {
|
if (resp.statusCode == 404) {
|
||||||
debugPrint("HTTP 404, api server doesn't support shared address book");
|
debugPrint("HTTP 404, api server doesn't support shared address book");
|
||||||
@@ -228,6 +229,7 @@ class AbModel {
|
|||||||
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
|
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
|
||||||
var headers = getHttpHeaders();
|
var headers = getHttpHeaders();
|
||||||
headers['Content-Type'] = "application/json";
|
headers['Content-Type'] = "application/json";
|
||||||
|
_setEmptyBody(headers);
|
||||||
final resp = await http.post(Uri.parse(api), headers: headers);
|
final resp = await http.post(Uri.parse(api), headers: headers);
|
||||||
if (resp.statusCode == 404) {
|
if (resp.statusCode == 404) {
|
||||||
debugPrint("HTTP 404, current api server is legacy mode");
|
debugPrint("HTTP 404, current api server is legacy mode");
|
||||||
@@ -269,6 +271,7 @@ class AbModel {
|
|||||||
});
|
});
|
||||||
var headers = getHttpHeaders();
|
var headers = getHttpHeaders();
|
||||||
headers['Content-Type'] = "application/json";
|
headers['Content-Type'] = "application/json";
|
||||||
|
_setEmptyBody(headers);
|
||||||
final resp = await http.post(uri, headers: headers);
|
final resp = await http.post(uri, headers: headers);
|
||||||
Map<String, dynamic> json =
|
Map<String, dynamic> json =
|
||||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||||
@@ -319,8 +322,8 @@ class AbModel {
|
|||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region peer
|
// #region peer
|
||||||
Future<String?> addIdToCurrent(
|
Future<String?> addIdToCurrent(String id, String alias, String password,
|
||||||
String id, String alias, String password, List<dynamic> tags) async {
|
List<dynamic> tags, String note) async {
|
||||||
if (currentAbPeers.where((element) => element.id == id).isNotEmpty) {
|
if (currentAbPeers.where((element) => element.id == id).isNotEmpty) {
|
||||||
return "$id already exists in address book $_currentName";
|
return "$id already exists in address book $_currentName";
|
||||||
}
|
}
|
||||||
@@ -333,6 +336,9 @@ class AbModel {
|
|||||||
if (password.isNotEmpty) {
|
if (password.isNotEmpty) {
|
||||||
peer['password'] = password;
|
peer['password'] = password;
|
||||||
}
|
}
|
||||||
|
if (note.isNotEmpty) {
|
||||||
|
peer['note'] = note;
|
||||||
|
}
|
||||||
final ret = await addPeersTo([peer], _currentName.value);
|
final ret = await addPeersTo([peer], _currentName.value);
|
||||||
_syncAllFromRecent = true;
|
_syncAllFromRecent = true;
|
||||||
return ret;
|
return ret;
|
||||||
@@ -376,6 +382,14 @@ class AbModel {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> changeNote({required String id, required String note}) async {
|
||||||
|
bool res = await current.changeNote(id: id, note: note);
|
||||||
|
await pullNonLegacyAfterChange();
|
||||||
|
currentAbPeers.refresh();
|
||||||
|
// no need to save cache
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
||||||
var ret = false;
|
var ret = false;
|
||||||
final personalAb = addressbooks[_personalAddressBookName];
|
final personalAb = addressbooks[_personalAddressBookName];
|
||||||
@@ -658,6 +672,15 @@ class AbModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getPeerNote(String id) {
|
||||||
|
final it = currentAbPeers.where((p0) => p0.id == id);
|
||||||
|
if (it.isEmpty) {
|
||||||
|
return '';
|
||||||
|
} else {
|
||||||
|
return it.first.note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Color getCurrentAbTagColor(String tag) {
|
Color getCurrentAbTagColor(String tag) {
|
||||||
if (tag == kUntagged) {
|
if (tag == kUntagged) {
|
||||||
return MyTheme.accent;
|
return MyTheme.accent;
|
||||||
@@ -863,6 +886,8 @@ abstract class BaseAb {
|
|||||||
|
|
||||||
Future<bool> changeAlias({required String id, required String alias});
|
Future<bool> changeAlias({required String id, required String alias});
|
||||||
|
|
||||||
|
Future<bool> changeNote({required String id, required String note});
|
||||||
|
|
||||||
Future<bool> changePersonalHashPassword(String id, String hash);
|
Future<bool> changePersonalHashPassword(String id, String hash);
|
||||||
|
|
||||||
Future<bool> changeSharedPassword(String id, String password);
|
Future<bool> changeSharedPassword(String id, String password);
|
||||||
@@ -990,16 +1015,8 @@ class LegacyAb extends BaseAb {
|
|||||||
var authHeaders = getHttpHeaders();
|
var authHeaders = getHttpHeaders();
|
||||||
authHeaders['Content-Type'] = "application/json";
|
authHeaders['Content-Type'] = "application/json";
|
||||||
final body = jsonEncode({"data": jsonEncode(_serialize())});
|
final body = jsonEncode({"data": jsonEncode(_serialize())});
|
||||||
http.Response resp;
|
http.Response resp =
|
||||||
// support compression
|
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (resp.statusCode == 200 &&
|
if (resp.statusCode == 200 &&
|
||||||
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
|
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
|
||||||
ret = true;
|
ret = true;
|
||||||
@@ -1090,6 +1107,12 @@ class LegacyAb extends BaseAb {
|
|||||||
return await pushAb();
|
return await pushAb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> changeNote({required String id, required String note}) async {
|
||||||
|
// no need to implement
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> changeSharedPassword(String id, String password) async {
|
Future<bool> changeSharedPassword(String id, String password) async {
|
||||||
// no need to implement
|
// no need to implement
|
||||||
@@ -1378,6 +1401,7 @@ class Ab extends BaseAb {
|
|||||||
});
|
});
|
||||||
var headers = getHttpHeaders();
|
var headers = getHttpHeaders();
|
||||||
headers['Content-Type'] = "application/json";
|
headers['Content-Type'] = "application/json";
|
||||||
|
_setEmptyBody(headers);
|
||||||
final resp = await http.post(uri, headers: headers);
|
final resp = await http.post(uri, headers: headers);
|
||||||
statusCode = resp.statusCode;
|
statusCode = resp.statusCode;
|
||||||
Map<String, dynamic> json =
|
Map<String, dynamic> json =
|
||||||
@@ -1435,6 +1459,7 @@ class Ab extends BaseAb {
|
|||||||
);
|
);
|
||||||
var headers = getHttpHeaders();
|
var headers = getHttpHeaders();
|
||||||
headers['Content-Type'] = "application/json";
|
headers['Content-Type'] = "application/json";
|
||||||
|
_setEmptyBody(headers);
|
||||||
final resp = await http.post(uri, headers: headers);
|
final resp = await http.post(uri, headers: headers);
|
||||||
statusCode = resp.statusCode;
|
statusCode = resp.statusCode;
|
||||||
List<dynamic> json =
|
List<dynamic> json =
|
||||||
@@ -1549,6 +1574,27 @@ class Ab extends BaseAb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> changeNote({required String id, required String note}) async {
|
||||||
|
try {
|
||||||
|
final api =
|
||||||
|
"${await bind.mainGetApiServer()}/api/ab/peer/update/${profile.guid}";
|
||||||
|
var headers = getHttpHeaders();
|
||||||
|
headers['Content-Type'] = "application/json";
|
||||||
|
final body = jsonEncode({"id": id, "note": note});
|
||||||
|
final resp = await http.put(Uri.parse(api), headers: headers, body: body);
|
||||||
|
final errMsg = _jsonDecodeActionResp(resp);
|
||||||
|
if (errMsg.isNotEmpty) {
|
||||||
|
BotToast.showText(contentColor: Colors.red, text: errMsg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('changeNote err: ${err.toString()}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _setPassword(Object bodyContent) async {
|
Future<bool> _setPassword(Object bodyContent) async {
|
||||||
try {
|
try {
|
||||||
final api =
|
final api =
|
||||||
@@ -1815,6 +1861,11 @@ class DummyAb extends BaseAb {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> changeNote({required String id, required String note}) async {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
||||||
return false;
|
return false;
|
||||||
@@ -1923,3 +1974,8 @@ String _jsonDecodeActionResp(http.Response resp) {
|
|||||||
}
|
}
|
||||||
return errMsg;
|
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(
|
: this(
|
||||||
connId: d['connId'] ?? 0,
|
connId: d['connId'] ?? 0,
|
||||||
id: int.tryParse(d['id'].toString()) ?? 0,
|
id: int.tryParse(d['id'].toString()) ?? 0,
|
||||||
path: d['path'] ?? '',
|
path: d['dataSource'] ?? '',
|
||||||
isRemote: d['isRemote'] ?? false,
|
isRemote: d['isRemote'] ?? false,
|
||||||
totalSize: d['totalSize'] ?? 0,
|
totalSize: d['totalSize'] ?? 0,
|
||||||
finishedSize: d['finishedSize'] ?? 0,
|
finishedSize: d['finishedSize'] ?? 0,
|
||||||
|
|||||||
@@ -113,6 +113,34 @@ class FileModel {
|
|||||||
fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']);
|
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 {
|
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||||
evtLoop.pushEvent(
|
evtLoop.pushEvent(
|
||||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||||
@@ -591,8 +619,21 @@ class FileController {
|
|||||||
} else if (item.isDirectory) {
|
} else if (item.isDirectory) {
|
||||||
title = translate("Not an empty directory");
|
title = translate("Not an empty directory");
|
||||||
dialogManager?.showLoading(translate("Waiting"));
|
dialogManager?.showLoading(translate("Waiting"));
|
||||||
final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
|
final FileDirectory fd;
|
||||||
jobID, item.path, items.isLocal, true);
|
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) {
|
if (fd.path.isEmpty) {
|
||||||
fd.path = item.path;
|
fd.path = item.path;
|
||||||
}
|
}
|
||||||
@@ -606,7 +647,7 @@ class FileController {
|
|||||||
item.name,
|
item.name,
|
||||||
false);
|
false);
|
||||||
if (confirm == true) {
|
if (confirm == true) {
|
||||||
sendRemoveEmptyDir(
|
await sendRemoveEmptyDir(
|
||||||
item.path,
|
item.path,
|
||||||
0,
|
0,
|
||||||
deleteJobId,
|
deleteJobId,
|
||||||
@@ -647,7 +688,7 @@ class FileController {
|
|||||||
// handle remove res;
|
// handle remove res;
|
||||||
if (item.isDirectory &&
|
if (item.isDirectory &&
|
||||||
res['file_num'] == (entries.length - 1).toString()) {
|
res['file_num'] == (entries.length - 1).toString()) {
|
||||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
jobController.updateJobStatus(deleteJobId,
|
jobController.updateJobStatus(deleteJobId,
|
||||||
@@ -660,7 +701,7 @@ class FileController {
|
|||||||
final res = await jobController.jobResultListener.start();
|
final res = await jobController.jobResultListener.start();
|
||||||
if (item.isDirectory &&
|
if (item.isDirectory &&
|
||||||
res['file_num'] == (entries.length - 1).toString()) {
|
res['file_num'] == (entries.length - 1).toString()) {
|
||||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -755,9 +796,9 @@ class FileController {
|
|||||||
fileNum: fileNum);
|
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));
|
history.removeWhere((element) => element.contains(path));
|
||||||
bind.sessionRemoveAllEmptyDirs(
|
await bind.sessionRemoveAllEmptyDirs(
|
||||||
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
|
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,30 +1074,54 @@ class JobController {
|
|||||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadLastJob(Map<String, dynamic> evt) {
|
Future<void> loadLastJob(Map<String, dynamic> evt) async {
|
||||||
debugPrint("load last job: $evt");
|
debugPrint("load last job: $evt");
|
||||||
Map<String, dynamic> jobDetail = json.decode(evt['value']);
|
Map<String, dynamic> jobDetail = json.decode(evt['value']);
|
||||||
// int id = int.parse(jobDetail['id']);
|
|
||||||
String remote = jobDetail['remote'];
|
String remote = jobDetail['remote'];
|
||||||
String to = jobDetail['to'];
|
String to = jobDetail['to'];
|
||||||
bool showHidden = jobDetail['show_hidden'];
|
bool showHidden = jobDetail['show_hidden'];
|
||||||
int fileNum = jobDetail['file_num'];
|
int fileNum = jobDetail['file_num'];
|
||||||
bool isRemote = jobDetail['is_remote'];
|
bool isRemote = jobDetail['is_remote'];
|
||||||
final currJobId = JobController.jobID.next();
|
bool isAutoStart = jobDetail['auto_start'] == true;
|
||||||
String fileName = path.basename(isRemote ? remote : to);
|
int currJobId = -1;
|
||||||
var jobProgress = JobProgress()
|
if (isAutoStart) {
|
||||||
..type = JobType.transfer
|
// Ensure jobDetail['id'] exists and is an int
|
||||||
..fileName = fileName
|
if (jobDetail.containsKey('id') &&
|
||||||
..jobName = isRemote ? remote : to
|
jobDetail['id'] != null &&
|
||||||
..id = currJobId
|
jobDetail['id'] is int) {
|
||||||
..isRemoteToLocal = isRemote
|
currJobId = jobDetail['id'];
|
||||||
..fileNum = fileNum
|
}
|
||||||
..remote = remote
|
}
|
||||||
..to = to
|
if (currJobId < 0) {
|
||||||
..showHidden = showHidden
|
// If id is missing or invalid, disable auto-start and assign a new job id
|
||||||
..state = JobState.paused;
|
isAutoStart = false;
|
||||||
jobTable.add(jobProgress);
|
currJobId = JobController.jobID.next();
|
||||||
bind.sessionAddJob(
|
}
|
||||||
|
|
||||||
|
if (!isAutoStart) {
|
||||||
|
if (!(isDesktop || isWebDesktop)) {
|
||||||
|
// Don't add to job table if not auto start on mobile.
|
||||||
|
// Because mobile does not support job list view now.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to job table if not auto start on desktop.
|
||||||
|
String fileName = path.basename(isRemote ? remote : to);
|
||||||
|
final jobProgress = JobProgress()
|
||||||
|
..type = JobType.transfer
|
||||||
|
..fileName = fileName
|
||||||
|
..jobName = isRemote ? remote : to
|
||||||
|
..id = currJobId
|
||||||
|
..isRemoteToLocal = isRemote
|
||||||
|
..fileNum = fileNum
|
||||||
|
..remote = remote
|
||||||
|
..to = to
|
||||||
|
..showHidden = showHidden
|
||||||
|
..state = JobState.paused;
|
||||||
|
jobTable.add(jobProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bind.sessionAddJob(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
isRemote: isRemote,
|
isRemote: isRemote,
|
||||||
includeHidden: showHidden,
|
includeHidden: showHidden,
|
||||||
@@ -1065,6 +1130,11 @@ class JobController {
|
|||||||
to: isRemote ? to : remote,
|
to: isRemote ? to : remote,
|
||||||
fileNum: fileNum,
|
fileNum: fileNum,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isAutoStart) {
|
||||||
|
await bind.sessionResumeJob(
|
||||||
|
sessionId: sessionId, actId: currJobId, isRemote: isRemote);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumeJob(int jobId) {
|
void resumeJob(int jobId) {
|
||||||
@@ -1095,6 +1165,11 @@ class JobController {
|
|||||||
}
|
}
|
||||||
debugPrint("update folder files: $info");
|
debugPrint("update folder files: $info");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
jobTable.clear();
|
||||||
|
jobResultListener.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JobResultListener<T> {
|
class JobResultListener<T> {
|
||||||
@@ -1241,6 +1316,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(
|
Future<List<FileDirectory>> readEmptyDirs(
|
||||||
String path, bool isLocal, bool showHidden) async {
|
String path, bool isLocal, bool showHidden) async {
|
||||||
try {
|
try {
|
||||||
@@ -1404,6 +1488,10 @@ class JobProgress {
|
|||||||
var err = "";
|
var err = "";
|
||||||
int lastTransferredSize = 0;
|
int lastTransferredSize = 0;
|
||||||
|
|
||||||
|
double get percent =>
|
||||||
|
totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0;
|
||||||
|
String get percentText => '${(percent * 100).toStringAsFixed(0)}%';
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
type = JobType.none;
|
type = JobType.none;
|
||||||
state = JobState.none;
|
state = JobState.none;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import 'package:get/get.dart';
|
|||||||
|
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
|
import '../../models/state_model.dart';
|
||||||
|
import 'relative_mouse_model.dart';
|
||||||
import '../common.dart';
|
import '../common.dart';
|
||||||
import '../consts.dart';
|
import '../consts.dart';
|
||||||
|
|
||||||
@@ -42,8 +44,7 @@ class CanvasCoords {
|
|||||||
'scale': scale,
|
'scale': scale,
|
||||||
'scrollX': scrollX,
|
'scrollX': scrollX,
|
||||||
'scrollY': scrollY,
|
'scrollY': scrollY,
|
||||||
'scrollStyle':
|
'scrollStyle': scrollStyle.toJson(),
|
||||||
scrollStyle == ScrollStyle.scrollauto ? 'scrollauto' : 'scrollbar',
|
|
||||||
'size': {
|
'size': {
|
||||||
'w': size.width,
|
'w': size.width,
|
||||||
'h': size.height,
|
'h': size.height,
|
||||||
@@ -58,9 +59,8 @@ class CanvasCoords {
|
|||||||
model.scale = json['scale'];
|
model.scale = json['scale'];
|
||||||
model.scrollX = json['scrollX'];
|
model.scrollX = json['scrollX'];
|
||||||
model.scrollY = json['scrollY'];
|
model.scrollY = json['scrollY'];
|
||||||
model.scrollStyle = json['scrollStyle'] == 'scrollauto'
|
model.scrollStyle =
|
||||||
? ScrollStyle.scrollauto
|
ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||||
: ScrollStyle.scrollbar;
|
|
||||||
model.size = Size(json['size']['w'], json['size']['h']);
|
model.size = Size(json['size']['w'], json['size']['h']);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@@ -348,18 +348,47 @@ class InputModel {
|
|||||||
final _trackpadAdjustPeerLinux = 0.06;
|
final _trackpadAdjustPeerLinux = 0.06;
|
||||||
// This is an experience value.
|
// This is an experience value.
|
||||||
final _trackpadAdjustMacToWin = 2.50;
|
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;
|
int _trackpadSpeed = kDefaultTrackpadSpeed;
|
||||||
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
|
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
|
||||||
var _trackpadScrollUnsent = Offset.zero;
|
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;
|
var _lastScale = 1.0;
|
||||||
|
|
||||||
bool _pointerMovedAfterEnter = false;
|
bool _pointerMovedAfterEnter = false;
|
||||||
|
bool _pointerInsideImage = false;
|
||||||
|
|
||||||
// mouse
|
// mouse
|
||||||
final isPhysicalMouse = false.obs;
|
final isPhysicalMouse = false.obs;
|
||||||
int _lastButtons = 0;
|
int _lastButtons = 0;
|
||||||
Offset lastMousePos = Offset.zero;
|
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;
|
bool _queryOtherWindowCoords = false;
|
||||||
Rect? _windowRect;
|
Rect? _windowRect;
|
||||||
@@ -370,14 +399,108 @@ class InputModel {
|
|||||||
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
||||||
String get id => parent.target?.id ?? '';
|
String get id => parent.target?.id ?? '';
|
||||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||||
|
String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
|
||||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||||
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
|
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
|
||||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||||
int get trackpadSpeed => _trackpadSpeed;
|
int get trackpadSpeed => _trackpadSpeed;
|
||||||
|
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) {
|
InputModel(this.parent) {
|
||||||
sessionId = parent.target!.sessionId;
|
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.
|
// This function must be called after the peer info is received.
|
||||||
@@ -508,6 +631,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;
|
final key = e.logicalKey;
|
||||||
if (e is RawKeyDownEvent) {
|
if (e is RawKeyDownEvent) {
|
||||||
if (!e.repeat) {
|
if (!e.repeat) {
|
||||||
@@ -544,7 +676,7 @@ class InputModel {
|
|||||||
|
|
||||||
// * Currently mobile does not enable map mode
|
// * Currently mobile does not enable map mode
|
||||||
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
|
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
|
||||||
mapKeyboardModeRaw(e);
|
mapKeyboardModeRaw(e, iosCapsLock);
|
||||||
} else {
|
} else {
|
||||||
legacyKeyboardModeRaw(e);
|
legacyKeyboardModeRaw(e);
|
||||||
}
|
}
|
||||||
@@ -570,6 +702,21 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
if (e is KeyUpEvent) {
|
if (e is KeyUpEvent) {
|
||||||
handleKeyUpEventModifiers(e);
|
handleKeyUpEventModifiers(e);
|
||||||
} else if (e is KeyDownEvent) {
|
} else if (e is KeyDownEvent) {
|
||||||
@@ -615,7 +762,8 @@ class InputModel {
|
|||||||
e.character ?? '',
|
e.character ?? '',
|
||||||
e.physicalKey.usbHidUsage & 0xFFFF,
|
e.physicalKey.usbHidUsage & 0xFFFF,
|
||||||
// Show repeat event be converted to "release+press" events?
|
// Show repeat event be converted to "release+press" events?
|
||||||
e is KeyDownEvent || e is KeyRepeatEvent);
|
e is KeyDownEvent || e is KeyRepeatEvent,
|
||||||
|
iosCapsLock);
|
||||||
} else {
|
} else {
|
||||||
legacyKeyboardMode(e);
|
legacyKeyboardMode(e);
|
||||||
}
|
}
|
||||||
@@ -624,23 +772,9 @@ class InputModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send Key Event
|
/// Send Key Event
|
||||||
void newKeyboardMode(String character, int usbHid, bool down) {
|
void newKeyboardMode(
|
||||||
const capslock = 1;
|
String character, int usbHid, bool down, bool iosCapsLock) {
|
||||||
const numlock = 2;
|
final lockModes = _buildLockModes(iosCapsLock);
|
||||||
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);
|
|
||||||
}
|
|
||||||
bind.sessionHandleFlutterKeyEvent(
|
bind.sessionHandleFlutterKeyEvent(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
character: character,
|
character: character,
|
||||||
@@ -649,7 +783,7 @@ class InputModel {
|
|||||||
downOrUp: down);
|
downOrUp: down);
|
||||||
}
|
}
|
||||||
|
|
||||||
void mapKeyboardModeRaw(RawKeyEvent e) {
|
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
|
||||||
int positionCode = -1;
|
int positionCode = -1;
|
||||||
int platformCode = -1;
|
int platformCode = -1;
|
||||||
bool down;
|
bool down;
|
||||||
@@ -680,27 +814,14 @@ class InputModel {
|
|||||||
} else {
|
} else {
|
||||||
down = false;
|
down = false;
|
||||||
}
|
}
|
||||||
inputRawKey(e.character ?? '', platformCode, positionCode, down);
|
inputRawKey(
|
||||||
|
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send raw Key Event
|
/// Send raw Key Event
|
||||||
void inputRawKey(String name, int platformCode, int positionCode, bool down) {
|
void inputRawKey(String name, int platformCode, int positionCode, bool down,
|
||||||
const capslock = 1;
|
bool iosCapsLock) {
|
||||||
const numlock = 2;
|
final lockModes = _buildLockModes(iosCapsLock);
|
||||||
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);
|
|
||||||
}
|
|
||||||
bind.sessionHandleFlutterRawKeyEvent(
|
bind.sessionHandleFlutterRawKeyEvent(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -766,9 +887,17 @@ class InputModel {
|
|||||||
command: command);
|
command: command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> getMouseEventMove() => {
|
||||||
|
'type': _kMouseEventMove,
|
||||||
|
'buttons': 0,
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
||||||
final Map<String, dynamic> out = {};
|
final Map<String, dynamic> out = {};
|
||||||
|
|
||||||
|
bool hasStaleButtonsOnMouseUp =
|
||||||
|
type == _kMouseEventUp && evt.buttons == _lastButtons;
|
||||||
|
|
||||||
// Check update event type and set buttons to be sent.
|
// Check update event type and set buttons to be sent.
|
||||||
int buttons = _lastButtons;
|
int buttons = _lastButtons;
|
||||||
if (type == _kMouseEventMove) {
|
if (type == _kMouseEventMove) {
|
||||||
@@ -793,7 +922,7 @@ class InputModel {
|
|||||||
buttons = evt.buttons;
|
buttons = evt.buttons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastButtons = evt.buttons;
|
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
|
||||||
|
|
||||||
out['buttons'] = buttons;
|
out['buttons'] = buttons;
|
||||||
out['type'] = type;
|
out['type'] = type;
|
||||||
@@ -850,11 +979,14 @@ class InputModel {
|
|||||||
toReleaseKeys.release(handleKeyEvent);
|
toReleaseKeys.release(handleKeyEvent);
|
||||||
toReleaseRawKeys.release(handleRawKeyEvent);
|
toReleaseRawKeys.release(handleRawKeyEvent);
|
||||||
_pointerMovedAfterEnter = false;
|
_pointerMovedAfterEnter = false;
|
||||||
|
_pointerInsideImage = enter;
|
||||||
|
_lastWheelTsUs = 0;
|
||||||
|
|
||||||
// Fix status
|
// Fix status
|
||||||
if (!enter) {
|
if (!enter) {
|
||||||
resetModifiers();
|
resetModifiers();
|
||||||
}
|
}
|
||||||
|
_relativeMouse.onEnterOrLeaveImage(enter);
|
||||||
_flingTimer?.cancel();
|
_flingTimer?.cancel();
|
||||||
if (!isInputSourceFlutter) {
|
if (!isInputSourceFlutter) {
|
||||||
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
|
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
|
||||||
@@ -875,15 +1007,142 @@ class InputModel {
|
|||||||
msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
|
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) {
|
void onPointHoverImage(PointerHoverEvent e) {
|
||||||
_stopFling = true;
|
_stopFling = true;
|
||||||
if (isViewOnly && !showMyCursor) return;
|
if (isViewOnly && !showMyCursor) return;
|
||||||
if (e.kind != ui.PointerDeviceKind.mouse) 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) {
|
if (!isPhysicalMouse.value) {
|
||||||
isPhysicalMouse.value = true;
|
isPhysicalMouse.value = true;
|
||||||
}
|
}
|
||||||
if (isPhysicalMouse.value) {
|
if (isPhysicalMouse.value) {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||||
|
edgeScroll: useEdgeScroll);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,6 +1178,7 @@ class InputModel {
|
|||||||
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
|
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
|
||||||
delta *= _trackpadAdjustMacToWin;
|
delta *= _trackpadAdjustMacToWin;
|
||||||
}
|
}
|
||||||
|
delta = _filterTrackpadDeltaAxis(delta);
|
||||||
_trackpadLastDelta = delta;
|
_trackpadLastDelta = delta;
|
||||||
|
|
||||||
var x = delta.dx.toInt();
|
var x = delta.dx.toInt();
|
||||||
@@ -951,6 +1211,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) {
|
void _scheduleFling(double x, double y, int delay) {
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return;
|
||||||
if ((x == 0 && y == 0) || _stopFling) {
|
if ((x == 0 && y == 0) || _stopFling) {
|
||||||
@@ -1032,6 +1310,28 @@ class InputModel {
|
|||||||
_trackpadLastDelta = Offset.zero;
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
void onPointDownImage(PointerDownEvent e) {
|
void onPointDownImage(PointerDownEvent e) {
|
||||||
debugPrint("onPointDownImage ${e.kind}");
|
debugPrint("onPointDownImage ${e.kind}");
|
||||||
_stopFling = true;
|
_stopFling = true;
|
||||||
@@ -1040,13 +1340,32 @@ class InputModel {
|
|||||||
_windowRect = null;
|
_windowRect = null;
|
||||||
if (isViewOnly && !showMyCursor) return;
|
if (isViewOnly && !showMyCursor) return;
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return;
|
||||||
|
|
||||||
|
// Track mouse down events for duplicate detection on iOS.
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (e.kind == ui.PointerDeviceKind.mouse) {
|
||||||
|
_lastMouseDownTimeMs = nowMs;
|
||||||
|
_lastMouseDownPos = e.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_relativeMouse.enabled.value) {
|
||||||
|
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||||
|
}
|
||||||
|
|
||||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||||
if (isPhysicalMouse.value) {
|
if (isPhysicalMouse.value) {
|
||||||
isPhysicalMouse.value = false;
|
isPhysicalMouse.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isPhysicalMouse.value) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,9 +1373,21 @@ class InputModel {
|
|||||||
if (isDesktop) _queryOtherWindowCoords = false;
|
if (isDesktop) _queryOtherWindowCoords = false;
|
||||||
if (isViewOnly && !showMyCursor) return;
|
if (isViewOnly && !showMyCursor) return;
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return;
|
||||||
|
|
||||||
|
if (_relativeMouse.enabled.value) {
|
||||||
|
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||||
|
}
|
||||||
|
|
||||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||||
if (isPhysicalMouse.value) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,6 +1395,11 @@ class InputModel {
|
|||||||
if (isViewOnly && !showMyCursor) return;
|
if (isViewOnly && !showMyCursor) return;
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return;
|
||||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||||
|
|
||||||
|
if (_relativeMouse.enabled.value) {
|
||||||
|
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||||
|
}
|
||||||
|
|
||||||
if (_queryOtherWindowCoords) {
|
if (_queryOtherWindowCoords) {
|
||||||
Future.delayed(Duration.zero, () async {
|
Future.delayed(Duration.zero, () async {
|
||||||
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
|
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
|
||||||
@@ -1071,7 +1407,10 @@ class InputModel {
|
|||||||
_queryOtherWindowCoords = false;
|
_queryOtherWindowCoords = false;
|
||||||
}
|
}
|
||||||
if (isPhysicalMouse.value) {
|
if (isPhysicalMouse.value) {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||||
|
edgeScroll: useEdgeScroll);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,21 +1434,53 @@ class InputModel {
|
|||||||
return null;
|
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) {
|
void onPointerSignalImage(PointerSignalEvent e) {
|
||||||
if (isViewOnly) return;
|
if (isViewOnly) return;
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return;
|
||||||
if (e is PointerScrollEvent) {
|
if (e is PointerScrollEvent) {
|
||||||
var dx = e.scrollDelta.dx.toInt();
|
final rawDx = e.scrollDelta.dx;
|
||||||
var dy = e.scrollDelta.dy.toInt();
|
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) {
|
if (dx > 0) {
|
||||||
dx = -1;
|
dx = -accel;
|
||||||
} else if (dx < 0) {
|
} else if (dx < 0) {
|
||||||
dx = 1;
|
dx = accel;
|
||||||
}
|
}
|
||||||
if (dy > 0) {
|
if (dy > 0) {
|
||||||
dy = -1;
|
dy = -accel;
|
||||||
} else if (dy < 0) {
|
} else if (dy < 0) {
|
||||||
dy = 1;
|
dy = accel;
|
||||||
}
|
}
|
||||||
bind.sessionSendMouse(
|
bind.sessionSendMouse(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
@@ -1120,7 +1491,7 @@ class InputModel {
|
|||||||
void refreshMousePos() => handleMouse({
|
void refreshMousePos() => handleMouse({
|
||||||
'buttons': 0,
|
'buttons': 0,
|
||||||
'type': _kMouseEventMove,
|
'type': _kMouseEventMove,
|
||||||
}, lastMousePos);
|
}, lastMousePos, edgeScroll: useEdgeScroll);
|
||||||
|
|
||||||
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
|
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
|
||||||
{
|
{
|
||||||
@@ -1222,16 +1593,18 @@ class InputModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleMouse(
|
Map<String, dynamic>? processEventToPeer(
|
||||||
Map<String, dynamic> evt,
|
Map<String, dynamic> evt,
|
||||||
Offset offset, {
|
Offset offset, {
|
||||||
bool onExit = false,
|
bool onExit = false,
|
||||||
|
bool moveCanvas = true,
|
||||||
|
bool edgeScroll = false,
|
||||||
}) {
|
}) {
|
||||||
if (isViewCamera) return;
|
if (isViewCamera) return null;
|
||||||
double x = offset.dx;
|
double x = offset.dx;
|
||||||
double y = max(0.0, offset.dy);
|
double y = max(0.0, offset.dy);
|
||||||
if (_checkPeerControlProtected(x, y)) {
|
if (_checkPeerControlProtected(x, y)) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var type = kMouseEventTypeDefault;
|
var type = kMouseEventTypeDefault;
|
||||||
@@ -1248,7 +1621,7 @@ class InputModel {
|
|||||||
isMove = true;
|
isMove = true;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
evt['type'] = type;
|
evt['type'] = type;
|
||||||
|
|
||||||
@@ -1266,9 +1639,11 @@ class InputModel {
|
|||||||
type,
|
type,
|
||||||
onExit: onExit,
|
onExit: onExit,
|
||||||
buttons: evt['buttons'],
|
buttons: evt['buttons'],
|
||||||
|
moveCanvas: moveCanvas,
|
||||||
|
edgeScroll: edgeScroll,
|
||||||
);
|
);
|
||||||
if (pos == null) {
|
if (pos == null) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (type != '') {
|
if (type != '') {
|
||||||
evt['x'] = '0';
|
evt['x'] = '0';
|
||||||
@@ -1278,15 +1653,35 @@ class InputModel {
|
|||||||
evt['y'] = '${pos.y.toInt()}';
|
evt['y'] = '${pos.y.toInt()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, String> mapButtons = {
|
final buttons = evt['buttons'];
|
||||||
kPrimaryMouseButton: 'left',
|
if (buttons is int) {
|
||||||
kSecondaryMouseButton: 'right',
|
evt['buttons'] = mouseButtonsToPeer(buttons);
|
||||||
kMiddleMouseButton: 'wheel',
|
} else {
|
||||||
kBackMouseButton: 'back',
|
// Log warning if buttons exists but is not an int (unexpected caller).
|
||||||
kForwardMouseButton: 'forward'
|
// Keep empty string fallback for missing buttons to preserve move/hover behavior.
|
||||||
};
|
if (buttons != null) {
|
||||||
evt['buttons'] = mapButtons[evt['buttons']] ?? '';
|
debugPrint(
|
||||||
bind.sessionSendMouse(sessionId: sessionId, msg: json.encode(modify(evt)));
|
'[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
|
||||||
|
}
|
||||||
|
evt['buttons'] = '';
|
||||||
|
}
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? handleMouse(
|
||||||
|
Map<String, dynamic> evt,
|
||||||
|
Offset offset, {
|
||||||
|
bool onExit = false,
|
||||||
|
bool moveCanvas = true,
|
||||||
|
bool edgeScroll = false,
|
||||||
|
}) {
|
||||||
|
final evtToPeer = processEventToPeer(evt, offset,
|
||||||
|
onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
|
||||||
|
if (evtToPeer != null) {
|
||||||
|
bind.sessionSendMouse(
|
||||||
|
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
|
||||||
|
}
|
||||||
|
return evtToPeer;
|
||||||
}
|
}
|
||||||
|
|
||||||
Point? handlePointerDevicePos(
|
Point? handlePointerDevicePos(
|
||||||
@@ -1297,6 +1692,8 @@ class InputModel {
|
|||||||
String evtType, {
|
String evtType, {
|
||||||
bool onExit = false,
|
bool onExit = false,
|
||||||
int buttons = kPrimaryMouseButton,
|
int buttons = kPrimaryMouseButton,
|
||||||
|
bool moveCanvas = true,
|
||||||
|
bool edgeScroll = false,
|
||||||
}) {
|
}) {
|
||||||
final ffiModel = parent.target!.ffiModel;
|
final ffiModel = parent.target!.ffiModel;
|
||||||
CanvasCoords canvas =
|
CanvasCoords canvas =
|
||||||
@@ -1326,7 +1723,15 @@ class InputModel {
|
|||||||
y -= CanvasModel.topToEdge;
|
y -= CanvasModel.topToEdge;
|
||||||
x -= CanvasModel.leftToEdge;
|
x -= CanvasModel.leftToEdge;
|
||||||
if (isMove) {
|
if (isMove) {
|
||||||
parent.target!.canvasModel.moveDesktopMouse(x, y);
|
final canvasModel = parent.target!.canvasModel;
|
||||||
|
|
||||||
|
if (edgeScroll) {
|
||||||
|
canvasModel.edgeScrollMouse(x, y);
|
||||||
|
} else if (moveCanvas) {
|
||||||
|
canvasModel.moveDesktopMouse(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasModel.updateLocalCursor(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _handlePointerDevicePos(
|
return _handlePointerDevicePos(
|
||||||
@@ -1389,7 +1794,7 @@ class InputModel {
|
|||||||
var nearBottom = (canvas.size.height - y) < nearThr;
|
var nearBottom = (canvas.size.height - y) < nearThr;
|
||||||
final imageWidth = rect.width * canvas.scale;
|
final imageWidth = rect.width * canvas.scale;
|
||||||
final imageHeight = rect.height * canvas.scale;
|
final imageHeight = rect.height * canvas.scale;
|
||||||
if (canvas.scrollStyle == ScrollStyle.scrollbar) {
|
if (canvas.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
x += imageWidth * canvas.scrollX;
|
x += imageWidth * canvas.scrollX;
|
||||||
y += imageHeight * canvas.scrollY;
|
y += imageHeight * canvas.scrollY;
|
||||||
|
|
||||||
@@ -1511,9 +1916,9 @@ class InputModel {
|
|||||||
// Simulate a key press event.
|
// Simulate a key press event.
|
||||||
// `usbHidUsage` is the USB HID usage code of the key.
|
// `usbHidUsage` is the USB HID usage code of the key.
|
||||||
Future<void> tapHidKey(int usbHidUsage) async {
|
Future<void> tapHidKey(int usbHidUsage) async {
|
||||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
|
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
|
||||||
await Future.delayed(Duration(milliseconds: 100));
|
await Future.delayed(Duration(milliseconds: 100));
|
||||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
|
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onMobileVolumeUp() async =>
|
Future<void> onMobileVolumeUp() async =>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ class Peer {
|
|||||||
bool online = false;
|
bool online = false;
|
||||||
String loginName; //login username
|
String loginName; //login username
|
||||||
String device_group_name;
|
String device_group_name;
|
||||||
|
String note;
|
||||||
bool? sameServer;
|
bool? sameServer;
|
||||||
|
|
||||||
String getId() {
|
String getId() {
|
||||||
@@ -43,6 +44,7 @@ class Peer {
|
|||||||
rdpUsername = json['rdpUsername'] ?? '',
|
rdpUsername = json['rdpUsername'] ?? '',
|
||||||
loginName = json['loginName'] ?? '',
|
loginName = json['loginName'] ?? '',
|
||||||
device_group_name = json['device_group_name'] ?? '',
|
device_group_name = json['device_group_name'] ?? '',
|
||||||
|
note = json['note'] is String ? json['note'] : '',
|
||||||
sameServer = json['same_server'];
|
sameServer = json['same_server'];
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
@@ -60,6 +62,7 @@ class Peer {
|
|||||||
"rdpUsername": rdpUsername,
|
"rdpUsername": rdpUsername,
|
||||||
'loginName': loginName,
|
'loginName': loginName,
|
||||||
'device_group_name': device_group_name,
|
'device_group_name': device_group_name,
|
||||||
|
'note': note,
|
||||||
'same_server': sameServer,
|
'same_server': sameServer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,7 @@ class Peer {
|
|||||||
required this.rdpUsername,
|
required this.rdpUsername,
|
||||||
required this.loginName,
|
required this.loginName,
|
||||||
required this.device_group_name,
|
required this.device_group_name,
|
||||||
|
required this.note,
|
||||||
this.sameServer,
|
this.sameServer,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ class Peer {
|
|||||||
rdpUsername: '',
|
rdpUsername: '',
|
||||||
loginName: '',
|
loginName: '',
|
||||||
device_group_name: '',
|
device_group_name: '',
|
||||||
|
note: '',
|
||||||
);
|
);
|
||||||
bool equal(Peer other) {
|
bool equal(Peer other) {
|
||||||
return id == other.id &&
|
return id == other.id &&
|
||||||
@@ -136,7 +141,8 @@ class Peer {
|
|||||||
rdpPort == other.rdpPort &&
|
rdpPort == other.rdpPort &&
|
||||||
rdpUsername == other.rdpUsername &&
|
rdpUsername == other.rdpUsername &&
|
||||||
device_group_name == other.device_group_name &&
|
device_group_name == other.device_group_name &&
|
||||||
loginName == other.loginName;
|
loginName == other.loginName &&
|
||||||
|
note == other.note;
|
||||||
}
|
}
|
||||||
|
|
||||||
Peer.copy(Peer other)
|
Peer.copy(Peer other)
|
||||||
@@ -154,6 +160,7 @@ class Peer {
|
|||||||
rdpUsername: other.rdpUsername,
|
rdpUsername: other.rdpUsername,
|
||||||
loginName: other.loginName,
|
loginName: other.loginName,
|
||||||
device_group_name: other.device_group_name,
|
device_group_name: other.device_group_name,
|
||||||
|
note: other.note,
|
||||||
sameServer: other.sameServer);
|
sameServer: other.sameServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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/chat_model.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../common.dart';
|
import '../common.dart';
|
||||||
@@ -51,6 +50,8 @@ class ServerModel with ChangeNotifier {
|
|||||||
|
|
||||||
Timer? cmHiddenTimer;
|
Timer? cmHiddenTimer;
|
||||||
|
|
||||||
|
final _wakelockKey = UniqueKey();
|
||||||
|
|
||||||
bool get isStart => _isStart;
|
bool get isStart => _isStart;
|
||||||
|
|
||||||
bool get mediaOk => _mediaOk;
|
bool get mediaOk => _mediaOk;
|
||||||
@@ -466,10 +467,8 @@ class ServerModel with ChangeNotifier {
|
|||||||
await parent.target?.invokeMethod("stop_service");
|
await parent.target?.invokeMethod("stop_service");
|
||||||
await bind.mainStopService();
|
await bind.mainStopService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (!isLinux) {
|
// for androidUpdatekeepScreenOn only
|
||||||
// current linux is not supported
|
WakelockManager.disable(_wakelockKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> setPermanentPassword(String newPW) async {
|
Future<bool> setPermanentPassword(String newPW) async {
|
||||||
@@ -797,12 +796,10 @@ class ServerModel with ChangeNotifier {
|
|||||||
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
|
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
|
||||||
(keepScreenOn == KeepScreenOn.duringControlled &&
|
(keepScreenOn == KeepScreenOn.duringControlled &&
|
||||||
_clients.map((e) => !e.disconnected).isNotEmpty);
|
_clients.map((e) => !e.disconnected).isNotEmpty);
|
||||||
if (on != await WakelockPlus.enabled) {
|
if (on) {
|
||||||
if (on) {
|
WakelockManager.enable(_wakelockKey, isServer: true);
|
||||||
WakelockPlus.enable();
|
} else {
|
||||||
} else {
|
WakelockManager.disable(_wakelockKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -823,6 +820,7 @@ class Client {
|
|||||||
bool isTerminal = false;
|
bool isTerminal = false;
|
||||||
String portForward = "";
|
String portForward = "";
|
||||||
String name = "";
|
String name = "";
|
||||||
|
String avatar = "";
|
||||||
String peerId = ""; // peer user's id,show at app
|
String peerId = ""; // peer user's id,show at app
|
||||||
bool keyboard = false;
|
bool keyboard = false;
|
||||||
bool clipboard = false;
|
bool clipboard = false;
|
||||||
@@ -850,6 +848,7 @@ class Client {
|
|||||||
isTerminal = json['is_terminal'] ?? false;
|
isTerminal = json['is_terminal'] ?? false;
|
||||||
portForward = json['port_forward'];
|
portForward = json['port_forward'];
|
||||||
name = json['name'];
|
name = json['name'];
|
||||||
|
avatar = json['avatar'] ?? '';
|
||||||
peerId = json['peer_id'];
|
peerId = json['peer_id'];
|
||||||
keyboard = json['keyboard'];
|
keyboard = json['keyboard'];
|
||||||
clipboard = json['clipboard'];
|
clipboard = json['clipboard'];
|
||||||
@@ -873,6 +872,7 @@ class Client {
|
|||||||
data['is_terminal'] = isTerminal;
|
data['is_terminal'] = isTerminal;
|
||||||
data['port_forward'] = portForward;
|
data['port_forward'] = portForward;
|
||||||
data['name'] = name;
|
data['name'] = name;
|
||||||
|
data['avatar'] = avatar;
|
||||||
data['peer_id'] = peerId;
|
data['peer_id'] = peerId;
|
||||||
data['keyboard'] = keyboard;
|
data['keyboard'] = keyboard;
|
||||||
data['clipboard'] = clipboard;
|
data['clipboard'] = clipboard;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
@@ -30,6 +29,11 @@ class StateGlobal {
|
|||||||
|
|
||||||
String _inputSource = '';
|
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
|
// Use for desktop -> remote toolbar -> resolution
|
||||||
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};
|
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,18 @@ class TerminalModel with ChangeNotifier {
|
|||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
final _inputBuffer = <String>[];
|
final _inputBuffer = <String>[];
|
||||||
|
// 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>[];
|
||||||
|
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 get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||||
|
|
||||||
|
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||||
|
|
||||||
Future<void> _handleInput(String data) async {
|
Future<void> _handleInput(String data) async {
|
||||||
// If we press the `Enter` button on Android,
|
// If we press the `Enter` button on Android,
|
||||||
// `data` can be '\r' or '\n' when using different keyboards.
|
// `data` can be '\r' or '\n' when using different keyboards.
|
||||||
@@ -68,6 +77,16 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
|
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
|
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
_markViewReady();
|
||||||
|
}
|
||||||
|
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
// Notify remote terminal of resize
|
// Notify remote terminal of resize
|
||||||
try {
|
try {
|
||||||
@@ -135,11 +154,15 @@ class TerminalModel with ChangeNotifier {
|
|||||||
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
||||||
// Optionally show error to user
|
// Optionally show error to user
|
||||||
if (e is TimeoutException) {
|
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 {
|
Future<void> closeTerminal() async {
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
try {
|
try {
|
||||||
@@ -243,8 +266,8 @@ class TerminalModel with ChangeNotifier {
|
|||||||
|
|
||||||
void _handleTerminalOpened(Map<String, dynamic> evt) {
|
void _handleTerminalOpened(Map<String, dynamic> evt) {
|
||||||
final bool success = getSuccessFromEvt(evt);
|
final bool success = getSuccessFromEvt(evt);
|
||||||
final String message = evt['message'] ?? '';
|
final String message = evt['message']?.toString() ?? '';
|
||||||
final String? serviceId = evt['service_id'];
|
final String? serviceId = evt['service_id']?.toString();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
|
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
|
||||||
@@ -252,7 +275,18 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (success) {
|
if (success) {
|
||||||
_terminalOpened = true;
|
_terminalOpened = true;
|
||||||
|
|
||||||
// Service ID is now saved on the Rust side in handle_terminal_response
|
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||||
|
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||||
|
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
_markViewReady();
|
||||||
|
}
|
||||||
|
|
||||||
// Process any buffered input
|
// Process any buffered input
|
||||||
_processBufferedInputAsync().then((_) {
|
_processBufferedInputAsync().then((_) {
|
||||||
@@ -273,7 +307,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
terminal.write('Failed to open terminal: $message\r\n');
|
_writeToTerminal('Failed to open terminal: $message\r\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,29 +351,82 @@ class TerminalModel with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.write(text);
|
_writeToTerminal(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[TerminalModel] Failed to process terminal data: $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) {
|
||||||
|
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);
|
||||||
|
_pendingOutputSize = truncated.length;
|
||||||
|
} else {
|
||||||
|
_pendingOutputChunks.add(text);
|
||||||
|
_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);
|
||||||
|
_pendingOutputSize -= removed.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminal.write(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flushOutputBuffer() {
|
||||||
|
if (_pendingOutputChunks.isEmpty) return;
|
||||||
|
debugPrint(
|
||||||
|
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||||
|
for (final chunk in _pendingOutputChunks) {
|
||||||
|
terminal.write(chunk);
|
||||||
|
}
|
||||||
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark terminal view as ready and flush buffered output.
|
||||||
|
void _markViewReady() {
|
||||||
|
if (_terminalViewReady) return;
|
||||||
|
_terminalViewReady = true;
|
||||||
|
_flushOutputBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||||
final int exitCode = evt['exit_code'] ?? 0;
|
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;
|
_terminalOpened = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||||
final String message = evt['message'] ?? 'Unknown error';
|
final String message = evt['message'] ?? 'Unknown error';
|
||||||
terminal.write('\r\nTerminal error: $message\r\n');
|
_writeToTerminal('\r\nTerminal error: $message\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
// Clear buffers to free memory
|
||||||
|
_inputBuffer.clear();
|
||||||
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSize = 0;
|
||||||
// Terminal cleanup is handled server-side when service closes
|
// Terminal cleanup is handled server-side when service closes
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,25 @@ bool refreshingUser = false;
|
|||||||
|
|
||||||
class UserModel {
|
class UserModel {
|
||||||
final RxString userName = ''.obs;
|
final RxString userName = ''.obs;
|
||||||
|
final RxString displayName = ''.obs;
|
||||||
|
final RxString avatar = ''.obs;
|
||||||
final RxBool isAdmin = false.obs;
|
final RxBool isAdmin = false.obs;
|
||||||
final RxString networkError = ''.obs;
|
final RxString networkError = ''.obs;
|
||||||
bool get isLogin => userName.isNotEmpty;
|
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;
|
WeakReference<FFI> parent;
|
||||||
|
|
||||||
UserModel(this.parent) {
|
UserModel(this.parent) {
|
||||||
@@ -98,7 +114,9 @@ class UserModel {
|
|||||||
_updateLocalUserInfo() {
|
_updateLocalUserInfo() {
|
||||||
final userInfo = getLocalUserInfo();
|
final userInfo = getLocalUserInfo();
|
||||||
if (userInfo != null) {
|
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();
|
await gFFI.groupModel.reset();
|
||||||
}
|
}
|
||||||
userName.value = '';
|
userName.value = '';
|
||||||
|
displayName.value = '';
|
||||||
|
avatar.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
_parseAndUpdateUser(UserPayload user) {
|
_parseAndUpdateUser(UserPayload user) {
|
||||||
userName.value = user.name;
|
userName.value = user.name;
|
||||||
|
displayName.value = user.displayName;
|
||||||
|
avatar.value = user.avatar;
|
||||||
isAdmin.value = user.isAdmin;
|
isAdmin.value = user.isAdmin;
|
||||||
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
|
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/platform_model.dart';
|
import '../models/platform_model.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
export 'package:http/http.dart' show Response;
|
export 'package:http/http.dart' show Response;
|
||||||
|
|
||||||
enum HttpMethod { get, post, put, delete }
|
enum HttpMethod { get, post, put, delete }
|
||||||
@@ -15,11 +17,19 @@ class HttpService {
|
|||||||
}) async {
|
}) async {
|
||||||
headers ??= {'Content-Type': 'application/json'};
|
headers ??= {'Content-Type': 'application/json'};
|
||||||
|
|
||||||
// Determine if there is currently a proxy setting, and if so, use FFI to call the Rust HTTP method.
|
// Use Rust HTTP implementation for non-web platforms for consistency.
|
||||||
final isProxy = await bind.mainGetProxyStatus();
|
var useFlutterHttp = (isWeb || kIsWeb);
|
||||||
|
if (!useFlutterHttp) {
|
||||||
|
final enableFlutterHttpOnRust =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionEnableFlutterHttpOnRust);
|
||||||
|
// Use flutter http if:
|
||||||
|
// Not `enableFlutterHttpOnRust` and no proxy is set
|
||||||
|
useFlutterHttp =
|
||||||
|
!(enableFlutterHttpOnRust || await bind.mainGetProxyStatus());
|
||||||
|
}
|
||||||
|
|
||||||
if (!isProxy) {
|
if (useFlutterHttp) {
|
||||||
return await _pollFultterHttp(url, method, headers: headers, body: body);
|
return await _pollFlutterHttp(url, method, headers: headers, body: body);
|
||||||
}
|
}
|
||||||
|
|
||||||
String headersJson = jsonEncode(headers);
|
String headersJson = jsonEncode(headers);
|
||||||
@@ -34,7 +44,7 @@ class HttpService {
|
|||||||
return _parseHttpResponse(resJson);
|
return _parseHttpResponse(resJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<http.Response> _pollFultterHttp(
|
Future<http.Response> _pollFlutterHttp(
|
||||||
Uri url,
|
Uri url,
|
||||||
HttpMethod method, {
|
HttpMethod method, {
|
||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
@@ -87,7 +97,8 @@ class HttpService {
|
|||||||
int statusCode = parsedJson['status_code'];
|
int statusCode = parsedJson['status_code'];
|
||||||
return http.Response(body, statusCode, headers: headers);
|
return http.Response(body, statusCode, headers: headers);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to parse response: $e');
|
print('Failed to parse response\n$responseJson\nError:\n$e');
|
||||||
|
throw Exception('Failed to parse response.\n$responseJson');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -475,7 +475,11 @@ class RustDeskMultiWindowManager {
|
|||||||
final shouldSavePos = type != WindowType.Terminal || i == windows.length - 1;
|
final shouldSavePos = type != WindowType.Terminal || i == windows.length - 1;
|
||||||
if (shouldSavePos) {
|
if (shouldSavePos) {
|
||||||
debugPrint("closing multi window, type: ${type.toString()} id: $wId");
|
debugPrint("closing multi window, type: ${type.toString()} id: $wId");
|
||||||
await saveWindowPosition(type, windowId: wId);
|
try {
|
||||||
|
await saveWindowPosition(type, windowId: wId);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to save window position of $wId, $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await WindowController.fromWindowId(wId).setPreventClose(false);
|
await WindowController.fromWindowId(wId).setPreventClose(false);
|
||||||
|
|||||||
@@ -13,8 +13,18 @@ class RdPlatformChannel {
|
|||||||
|
|
||||||
static RdPlatformChannel get instance => _windowUtil;
|
static RdPlatformChannel get instance => _windowUtil;
|
||||||
|
|
||||||
final MethodChannel _osxMethodChannel =
|
final MethodChannel _hostMethodChannel =
|
||||||
MethodChannel("org.rustdesk.rustdesk/macos");
|
MethodChannel("org.rustdesk.rustdesk/host");
|
||||||
|
|
||||||
|
/// Bump the position of the mouse cursor, if applicable
|
||||||
|
Future<bool> bumpMouse({required int dx, required int dy}) async {
|
||||||
|
// No debug output; this call is too chatty.
|
||||||
|
|
||||||
|
bool? result = await _hostMethodChannel
|
||||||
|
.invokeMethod("bumpMouse", {"dx": dx, "dy": dy});
|
||||||
|
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Change the theme of the system window
|
/// Change the theme of the system window
|
||||||
Future<void> changeSystemWindowTheme(SystemWindowTheme theme) {
|
Future<void> changeSystemWindowTheme(SystemWindowTheme theme) {
|
||||||
@@ -23,13 +33,13 @@ class RdPlatformChannel {
|
|||||||
print(
|
print(
|
||||||
"[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}");
|
"[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}");
|
||||||
}
|
}
|
||||||
return _osxMethodChannel
|
return _hostMethodChannel
|
||||||
.invokeMethod("setWindowTheme", {"themeName": theme.name});
|
.invokeMethod("setWindowTheme", {"themeName": theme.name});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminate .app manually.
|
/// Terminate .app manually.
|
||||||
Future<void> terminate() {
|
Future<void> terminate() {
|
||||||
assert(isMacOS);
|
assert(isMacOS);
|
||||||
return _osxMethodChannel.invokeMethod("terminate");
|
return _hostMethodChannel.invokeMethod("terminate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
flutter/lib/utils/scale.dart
Normal file
34
flutter/lib/utils/scale.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Clamp custom scale percent to supported bounds.
|
||||||
|
/// Keep this in sync with the slider's minimum in the desktop toolbar UI.
|
||||||
|
///
|
||||||
|
/// This function exists to ensure consistent clamping behavior across the app
|
||||||
|
/// and to provide a single point of reference for the valid scale range.
|
||||||
|
int clampCustomScalePercent(int percent) {
|
||||||
|
return percent.clamp(kScaleCustomMinPercent, kScaleCustomMaxPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a string percent and clamp. Defaults to 100 when invalid.
|
||||||
|
int parseCustomScalePercent(String? s, {int defaultPercent = 100}) {
|
||||||
|
final parsed = int.tryParse(s ?? '') ?? defaultPercent;
|
||||||
|
return clampCustomScalePercent(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a percent value to scale factor after clamping.
|
||||||
|
double percentToScale(int percent) => clampCustomScalePercent(percent) / 100.0;
|
||||||
|
|
||||||
|
/// Fetch, parse and clamp the custom scale percent for a session.
|
||||||
|
Future<int> getSessionCustomScalePercent(UuidValue sessionId) async {
|
||||||
|
final opt = await bind.sessionGetFlutterOption(
|
||||||
|
sessionId: sessionId, k: kCustomScalePercentKey);
|
||||||
|
return parseCustomScalePercent(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and compute the custom scale factor for a session.
|
||||||
|
Future<double> getSessionCustomScale(UuidValue sessionId) async {
|
||||||
|
final p = await getSessionCustomScalePercent(sessionId);
|
||||||
|
return percentToScale(p);
|
||||||
|
}
|
||||||
@@ -812,7 +812,7 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mainGetAppNameSync({dynamic hint}) {
|
String mainGetAppNameSync({dynamic hint}) {
|
||||||
return 'RustDesk';
|
return js.context.callMethod('getByName', ['app-name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
String mainUriPrefixSync({dynamic hint}) {
|
String mainUriPrefixSync({dynamic hint}) {
|
||||||
@@ -1609,23 +1609,28 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isCustomClient({dynamic hint}) {
|
bool isCustomClient({dynamic hint}) {
|
||||||
return false;
|
// is_custom_client() checks if app name is not "RustDesk"
|
||||||
|
return mainGetAppNameSync(hint: hint) != "RustDesk";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDisableSettings({dynamic hint}) {
|
bool isDisableSettings({dynamic hint}) {
|
||||||
return false;
|
// Checks HARD_SETTINGS["disable-settings"] == "Y"
|
||||||
|
return mainGetHardOption(key: "disable-settings", hint: hint) == "Y";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDisableAb({dynamic hint}) {
|
bool isDisableAb({dynamic hint}) {
|
||||||
return false;
|
// Checks HARD_SETTINGS["disable-ab"] == "Y"
|
||||||
|
return mainGetHardOption(key: "disable-ab", hint: hint) == "Y";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDisableGroupPanel({dynamic hint}) {
|
bool isDisableGroupPanel({dynamic hint}) {
|
||||||
return false;
|
// Checks LocalConfig::get_option("disable-group-panel") == "Y"
|
||||||
|
return mainGetLocalOption(key: "disable-group-panel", hint: hint) == "Y";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDisableAccount({dynamic hint}) {
|
bool isDisableAccount({dynamic hint}) {
|
||||||
return false;
|
// Checks HARD_SETTINGS["disable-account"] == "Y"
|
||||||
|
return mainGetHardOption(key: "disable-account", hint: hint) == "Y";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDisableInstallation({dynamic hint}) {
|
bool isDisableInstallation({dynamic hint}) {
|
||||||
@@ -1748,7 +1753,7 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mainGetHardOption({required String key, dynamic hint}) {
|
String mainGetHardOption({required String key, dynamic hint}) {
|
||||||
throw UnimplementedError("mainGetHardOption");
|
return mainGetLocalOption(key: key, hint: hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> mainCheckHwcodec({dynamic hint}) {
|
Future<void> mainCheckHwcodec({dynamic hint}) {
|
||||||
@@ -1821,7 +1826,7 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mainGetBuildinOption({required String key, dynamic hint}) {
|
String mainGetBuildinOption({required String key, dynamic hint}) {
|
||||||
return '';
|
return mainGetLocalOption(key: key, hint: hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
String installInstallOptions({dynamic hint}) {
|
String installInstallOptions({dynamic hint}) {
|
||||||
@@ -1979,5 +1984,59 @@ class RustdeskImpl {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int?> sessionGetEdgeScrollEdgeThickness(
|
||||||
|
{required UuidValue sessionId, dynamic hint}) {
|
||||||
|
final thickness = js.context.callMethod(
|
||||||
|
'getByName', ['option:session', 'edge-scroll-edge-thickness']);
|
||||||
|
return Future(() => int.tryParse(thickness) ?? 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sessionSetEdgeScrollEdgeThickness(
|
||||||
|
{required UuidValue sessionId, required int value, dynamic hint}) {
|
||||||
|
return Future(() => js.context.callMethod('setByName',
|
||||||
|
['option:session', 'edge-scroll-edge-thickness', value.toString()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionGetConnSessionId({required UuidValue sessionId, dynamic hint}) {
|
||||||
|
return js.context.callMethod('getByName', ['conn_session_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool willSessionCloseCloseSession(
|
||||||
|
{required UuidValue sessionId, dynamic hint}) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionGetLastAuditNote({required UuidValue sessionId, dynamic hint}) {
|
||||||
|
return js.context.callMethod('getByName', ['last_audit_note']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sessionSetAuditGuid(
|
||||||
|
{required UuidValue sessionId, required String guid, dynamic hint}) {
|
||||||
|
return Future(
|
||||||
|
() => js.context.callMethod('setByName', ['audit_guid', guid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionGetAuditGuid({required UuidValue sessionId, dynamic hint}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
void dispose() {}
|
void dispose() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project-level configuration.
|
# Project-level configuration.
|
||||||
cmake_minimum_required(VERSION 3.10)
|
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 name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
@@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
|||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
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}")
|
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||||
|
|
||||||
# Define the application target. To change its name, change BINARY_NAME above,
|
# Define the application target. To change its name, change BINARY_NAME above,
|
||||||
@@ -63,7 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
|||||||
add_executable(${BINARY_NAME}
|
add_executable(${BINARY_NAME}
|
||||||
"main.cc"
|
"main.cc"
|
||||||
"my_application.cc"
|
"my_application.cc"
|
||||||
|
"wayland_shortcuts_inhibit.cc"
|
||||||
|
"bump_mouse.cc"
|
||||||
|
"bump_mouse_x11.cc"
|
||||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||||
|
${WAYLAND_PROTOCOL_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply the standard set of build settings. This can be removed for applications
|
# Apply the standard set of build settings. This can be removed for applications
|
||||||
@@ -76,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 ${CMAKE_DL_LIBS})
|
||||||
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
|
# 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.
|
# Run the Flutter tool portions of the build. This must not be removed.
|
||||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
|
|
||||||
|
|||||||
18
flutter/linux/bump_mouse.cc
Normal file
18
flutter/linux/bump_mouse.cc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#include "bump_mouse.h"
|
||||||
|
|
||||||
|
#include "bump_mouse_x11.h"
|
||||||
|
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
|
||||||
|
bool bump_mouse(int dx, int dy)
|
||||||
|
{
|
||||||
|
GdkDisplay *display = gdk_display_get_default();
|
||||||
|
|
||||||
|
if (GDK_IS_X11_DISPLAY(display)) {
|
||||||
|
return bump_mouse_x11(dx, dy);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Don't know how to support this.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
flutter/linux/bump_mouse.h
Normal file
3
flutter/linux/bump_mouse.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
bool bump_mouse(int dx, int dy);
|
||||||
30
flutter/linux/bump_mouse_x11.cc
Normal file
30
flutter/linux/bump_mouse_x11.cc
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#include "bump_mouse.h"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
bool bump_mouse_x11(int dx, int dy)
|
||||||
|
{
|
||||||
|
GdkDevice *mouse_device;
|
||||||
|
|
||||||
|
#if GTK_CHECK_VERSION(3, 20, 0)
|
||||||
|
auto seat = gdk_display_get_default_seat(gdk_display_get_default());
|
||||||
|
|
||||||
|
mouse_device = gdk_seat_get_pointer(seat);
|
||||||
|
#else
|
||||||
|
auto devman = gdk_display_get_device_manager(gdk_display_get_default());
|
||||||
|
|
||||||
|
mouse_device = gdk_device_manager_get_client_pointer(devman);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
GdkScreen *screen;
|
||||||
|
gint x, y;
|
||||||
|
|
||||||
|
gdk_device_get_position(mouse_device, &screen, &x, &y);
|
||||||
|
gdk_device_warp(mouse_device, screen, x + dx, y + dy);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
3
flutter/linux/bump_mouse_x11.h
Normal file
3
flutter/linux/bump_mouse_x11.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
bool bump_mouse_x11(int dx, int dy);
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
#include "my_application.h"
|
#include "my_application.h"
|
||||||
|
|
||||||
|
#include "bump_mouse.h"
|
||||||
|
|
||||||
#include <flutter_linux/flutter_linux.h>
|
#include <flutter_linux/flutter_linux.h>
|
||||||
#ifdef GDK_WINDOWING_X11
|
#ifdef GDK_WINDOWING_X11
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#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"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
struct _MyApplication {
|
struct _MyApplication {
|
||||||
GtkApplication parent_instance;
|
GtkApplication parent_instance;
|
||||||
char** dart_entrypoint_arguments;
|
char** dart_entrypoint_arguments;
|
||||||
|
FlMethodChannel* host_channel;
|
||||||
};
|
};
|
||||||
|
|
||||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||||
|
|
||||||
|
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data);
|
||||||
|
|
||||||
GtkWidget *find_gl_area(GtkWidget *widget);
|
GtkWidget *find_gl_area(GtkWidget *widget);
|
||||||
void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
|
void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
|
||||||
|
|
||||||
@@ -24,6 +34,7 @@ GtkWidget *find_gl_area(GtkWidget *widget);
|
|||||||
// Implements GApplication::activate.
|
// Implements GApplication::activate.
|
||||||
static void my_application_activate(GApplication* application) {
|
static void my_application_activate(GApplication* application) {
|
||||||
MyApplication* self = MY_APPLICATION(application);
|
MyApplication* self = MY_APPLICATION(application);
|
||||||
|
|
||||||
GtkWindow* window =
|
GtkWindow* window =
|
||||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||||
gtk_window_set_decorated(window, FALSE);
|
gtk_window_set_decorated(window, FALSE);
|
||||||
@@ -85,8 +96,26 @@ static void my_application_activate(GApplication* application) {
|
|||||||
gtk_widget_show(GTK_WIDGET(window));
|
gtk_widget_show(GTK_WIDGET(window));
|
||||||
gtk_widget_show(GTK_WIDGET(view));
|
gtk_widget_show(GTK_WIDGET(view));
|
||||||
|
|
||||||
|
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||||
|
// Register callback for sub-windows created by desktop_multi_window plugin
|
||||||
|
// Only sub-windows (remote windows) need keyboard shortcuts inhibition
|
||||||
|
desktop_multi_window_plugin_set_window_created_callback(
|
||||||
|
(WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow);
|
||||||
|
#endif
|
||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
self->host_channel = fl_method_channel_new(
|
||||||
|
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
|
||||||
|
"org.rustdesk.rustdesk/host",
|
||||||
|
FL_METHOD_CODEC(codec));
|
||||||
|
fl_method_channel_set_method_call_handler(
|
||||||
|
self->host_channel,
|
||||||
|
host_channel_call_handler,
|
||||||
|
self,
|
||||||
|
nullptr);
|
||||||
|
|
||||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +142,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch
|
|||||||
static void my_application_dispose(GObject* object) {
|
static void my_application_dispose(GObject* object) {
|
||||||
MyApplication* self = MY_APPLICATION(object);
|
MyApplication* self = MY_APPLICATION(object);
|
||||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||||
|
g_clear_object(&self->host_channel);
|
||||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +161,61 @@ MyApplication* my_application_new() {
|
|||||||
nullptr));
|
nullptr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data)
|
||||||
|
{
|
||||||
|
if (strcmp(fl_method_call_get_name(method_call), "bumpMouse") == 0) {
|
||||||
|
FlValue *args = fl_method_call_get_args(method_call);
|
||||||
|
|
||||||
|
FlValue *dxValue = nullptr;
|
||||||
|
FlValue *dyValue = nullptr;
|
||||||
|
|
||||||
|
switch (fl_value_get_type(args))
|
||||||
|
{
|
||||||
|
case FL_VALUE_TYPE_MAP:
|
||||||
|
{
|
||||||
|
dxValue = fl_value_lookup_string(args, "dx");
|
||||||
|
dyValue = fl_value_lookup_string(args, "dy");
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FL_VALUE_TYPE_LIST:
|
||||||
|
{
|
||||||
|
int listSize = fl_value_get_length(args);
|
||||||
|
|
||||||
|
dxValue = (listSize >= 1) ? fl_value_get_list_value(args, 0) : nullptr;
|
||||||
|
dyValue = (listSize >= 2) ? fl_value_get_list_value(args, 1) : nullptr;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int dx = 0, dy = 0;
|
||||||
|
|
||||||
|
if (dxValue && (fl_value_get_type(dxValue) == FL_VALUE_TYPE_INT)) {
|
||||||
|
dx = fl_value_get_int(dxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dyValue && (fl_value_get_type(dyValue) == FL_VALUE_TYPE_INT)) {
|
||||||
|
dy = fl_value_get_int(dyValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool result = bump_mouse(dx, dy);
|
||||||
|
|
||||||
|
FlValue *result_value = fl_value_new_bool(result);
|
||||||
|
|
||||||
|
GError *error = nullptr;
|
||||||
|
|
||||||
|
if (!fl_method_call_respond_success(method_call, result_value, &error)) {
|
||||||
|
g_warning("Failed to send Flutter Platform Channel response: %s", error->message);
|
||||||
|
g_error_free(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fl_value_unref(result_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GtkWidget *find_gl_area(GtkWidget *widget)
|
GtkWidget *find_gl_area(GtkWidget *widget)
|
||||||
{
|
{
|
||||||
if (GTK_IS_GL_AREA(widget)) {
|
if (GTK_IS_GL_AREA(widget)) {
|
||||||
|
|||||||
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 window_size
|
||||||
import texture_rgba_renderer
|
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 {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
rustdesk_core_main();
|
rustdesk_core_main();
|
||||||
@@ -64,8 +80,106 @@ class MainFlutterWindow: NSWindow {
|
|||||||
window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua)
|
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) {
|
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
|
||||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/macos", binaryMessenger: registrar.messenger)
|
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
|
||||||
channel.setMethodCallHandler({
|
channel.setMethodCallHandler({
|
||||||
(call, result) -> Void in
|
(call, result) -> Void in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
@@ -96,9 +210,74 @@ class MainFlutterWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
case "requestRecordAudio":
|
case "requestRecordAudio":
|
||||||
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
|
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
|
||||||
result(granted)
|
DispatchQueue.main.async {
|
||||||
|
result(granted)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case "bumpMouse":
|
||||||
|
var dx = 0
|
||||||
|
var dy = 0
|
||||||
|
|
||||||
|
if let argMap = call.arguments as? [String: Any] {
|
||||||
|
dx = (argMap["dx"] as? Int) ?? 0
|
||||||
|
dy = (argMap["dy"] as? Int) ?? 0
|
||||||
|
}
|
||||||
|
else if let argList = call.arguments as? [Any] {
|
||||||
|
dx = argList.count >= 1 ? (argList[0] as? Int) ?? 0 : 0
|
||||||
|
dy = argList.count >= 2 ? (argList[1] as? Int) ?? 0 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var mouseLoc: CGPoint
|
||||||
|
|
||||||
|
if let dummyEvent = CGEvent(source: nil) { // can this ever fail?
|
||||||
|
mouseLoc = dummyEvent.location
|
||||||
|
}
|
||||||
|
else if let screenFrame = NSScreen.screens.first?.frame {
|
||||||
|
// NeXTStep: Origin is lower-left of primary screen, positive is up
|
||||||
|
// Cocoa Core Graphics: Origin is upper-left of primary screen, positive is down
|
||||||
|
let nsMouseLoc = NSEvent.mouseLocation
|
||||||
|
|
||||||
|
mouseLoc = CGPoint(
|
||||||
|
x: nsMouseLoc.x,
|
||||||
|
y: NSHeight(screenFrame) - nsMouseLoc.y)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let newLoc = CGPoint(x: mouseLoc.x + CGFloat(dx), y: mouseLoc.y + CGFloat(dy))
|
||||||
|
|
||||||
|
CGDisplayMoveCursorToPoint(0, newLoc)
|
||||||
|
|
||||||
|
// By default, Cocoa suppresses mouse events briefly after a call to warp the
|
||||||
|
// cursor to a new location. This is good if you want to draw the user's
|
||||||
|
// attention to the fact that the mouse is now in a particular location, but
|
||||||
|
// it's bad in this case; we get called as part of the handling of edge
|
||||||
|
// scrolling, which means the mouse is typically still in motion, and we want
|
||||||
|
// the cursor to keep moving smoothly uninterrupted.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
case "enableNativeRelativeMouseMode":
|
||||||
|
let success = self.enableNativeRelativeMouseMode(channel: channel)
|
||||||
|
result(success)
|
||||||
|
|
||||||
|
case "disableNativeRelativeMouseMode":
|
||||||
|
self.disableNativeRelativeMouseMode()
|
||||||
|
result(true)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
result(FlutterMethodNotImplemented)
|
result(FlutterMethodNotImplemented)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user