mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-16 17:31:04 +03:00
Compare commits
65 Commits
66ab0b87f6
...
worktree-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2927046f8 | ||
|
|
c794bee3c5 | ||
|
|
163cdb91b2 | ||
|
|
de052b2aaf | ||
|
|
89b3f88286 | ||
|
|
7587cf514a | ||
|
|
1bb4db9484 | ||
|
|
21b4e04e52 | ||
|
|
c5dd8f1e23 | ||
|
|
2a1b1c8cba | ||
|
|
1a8c47f541 | ||
|
|
46a35faf04 | ||
|
|
39a388cf50 | ||
|
|
eb404d01f9 | ||
|
|
d52a6647b8 | ||
|
|
8376431a19 | ||
|
|
054d863a6a | ||
|
|
0fcba29eb5 | ||
|
|
b610b9250c | ||
|
|
b0f26aabc3 | ||
|
|
c109145c54 | ||
|
|
babac46999 | ||
|
|
5693435f7a | ||
|
|
9c8e5dbb98 | ||
|
|
18e07563e2 | ||
|
|
fee3efcd8f | ||
|
|
de46cac9a7 | ||
|
|
7b901de8ff | ||
|
|
52baf741fb | ||
|
|
66d66bf1a6 | ||
|
|
85805c41b1 | ||
|
|
216b1d99e6 | ||
|
|
85b1986df4 | ||
|
|
0978786c7e | ||
|
|
0bf7b0cd26 | ||
|
|
83933e0242 | ||
|
|
4b708edba6 | ||
|
|
cc9343e3d3 | ||
|
|
8101bd2303 | ||
|
|
247acc1c97 | ||
|
|
798c8b22ae | ||
|
|
350ad893f8 | ||
|
|
618bf37deb | ||
|
|
c1a587cfa4 | ||
|
|
9a1c8da143 | ||
|
|
978c901f49 | ||
|
|
d453a19601 | ||
|
|
50c4e435de | ||
|
|
d5c6d0f6b7 | ||
|
|
b6ff62c74b | ||
|
|
ba6de7990f | ||
|
|
a59ad333fc | ||
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 | ||
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd | ||
|
|
f28ac38ccf | ||
|
|
28cf1836e6 | ||
|
|
1ec1b9e7e3 | ||
|
|
2c84c8fb13 |
89
.github/workflows/flutter-build.yml
vendored
89
.github/workflows/flutter-build.yml
vendored
@@ -31,7 +31,7 @@ env:
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.9"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
@@ -43,8 +43,9 @@ env:
|
||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_CMAKE_VERSION: "4.3.0"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.9"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -389,6 +390,54 @@ jobs:
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Build pre-built MSI template
|
||||
# Two things this works around: preprocess.py rewrites res/msi in place, so the
|
||||
# tree is reset around this second variant; and it locates the app as
|
||||
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
|
||||
#
|
||||
# The placeholder is chosen to keep this template as close to the shipped msi as
|
||||
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
|
||||
# derives no short name for it. A longer placeholder would get one, and a patch
|
||||
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
|
||||
#
|
||||
# It still has to be unique, which is why "RustDesk" itself cannot be used:
|
||||
# it also names payload that must never be renamed, such as librustdesk.dll
|
||||
# and drivers\RustDeskPrinterDriver.
|
||||
#
|
||||
#
|
||||
# Building the arm64 template on the native arm64 runner makes the ARM
|
||||
# package available: the build agents are x64 and cannot run
|
||||
# preprocess.py against an ARM exe.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
git checkout -- res/msi
|
||||
cp -r ./rustdesk ./rustdesk-msi-template
|
||||
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
|
||||
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
|
||||
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
|
||||
New-Item -ItemType Directory -Force -Path $assets | Out-Null
|
||||
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
|
||||
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
|
||||
}
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
popd
|
||||
mkdir ./msi-template
|
||||
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
|
||||
git checkout -- res/msi
|
||||
rm -r -fo ./rustdesk-msi-template
|
||||
|
||||
- name: Upload unsigned msi template
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
|
||||
path: ./msi-template
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
@@ -925,15 +974,33 @@ jobs:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-aarch64
|
||||
path: ./windows-aarch64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-x86_64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-aarch64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Combine unsigned app
|
||||
run: |
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
@@ -1470,7 +1537,6 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
@@ -1505,6 +1571,15 @@ jobs:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
|
||||
- name: Install CMake for vcpkg on Linux ARM64
|
||||
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
|
||||
user_base="$(python3 -m site --user-base)"
|
||||
"${user_base}/bin/cmake" --version
|
||||
echo "${user_base}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -2075,6 +2150,12 @@ jobs:
|
||||
echo "Modified vcpkg.json for armv7 build:"
|
||||
grep -A 2 -B 2 '"baseline"' vcpkg.json
|
||||
|
||||
- name: Set Swap Space
|
||||
if: matrix.job.arch == 'armv7'
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
|
||||
8
.github/workflows/playground.yml
vendored
8
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VERSION: "1.4.9"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
nasm \
|
||||
yasm \
|
||||
ninja-build \
|
||||
openjdk-11-jdk-headless \
|
||||
openjdk-17-jdk-headless \
|
||||
pkg-config \
|
||||
tree \
|
||||
wget
|
||||
@@ -365,9 +365,9 @@ jobs:
|
||||
- name: Build rustdesk
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
# temporary use debug sign config
|
||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||
case ${{ matrix.job.target }} in
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -74,6 +74,25 @@
|
||||
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
### Scope check before touching shared code
|
||||
|
||||
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
|
||||
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
|
||||
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
|
||||
|
||||
### Mandatory regression-surface check
|
||||
|
||||
Before considering any implementation complete, perform a minimization pass over the final diff.
|
||||
|
||||
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
|
||||
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
|
||||
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
|
||||
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
|
||||
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
|
||||
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
|
||||
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
|
||||
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
|
||||
|
||||
## Reviewing a PR
|
||||
|
||||
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -7177,7 +7177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7287,7 +7287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -82,7 +82,8 @@ protobuf {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.carriez.flutter_hbb"
|
||||
compileSdkVersion 36
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
@@ -91,6 +92,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
@@ -99,7 +101,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@@ -128,6 +130,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -89,7 +91,12 @@
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse|mediaProjection|microphone">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="@string/foreground_service_special_use_subtype" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".FloatingWindowService"
|
||||
|
||||
@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
|
||||
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
|
||||
|
||||
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
|
||||
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
|
||||
companion object {
|
||||
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
|
||||
private const val NO_ACTIVE_PUBLISHERS = 0
|
||||
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
|
||||
|
||||
@Synchronized
|
||||
private fun acquireAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
}
|
||||
activeAudioFramePublishers++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
Log.e(LOG_TAG, "No active audio frame publisher to release")
|
||||
return
|
||||
}
|
||||
activeAudioFramePublishers--
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = LOG_TAG
|
||||
|
||||
private var audioRecorder: AudioRecord? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return
|
||||
}
|
||||
// read f32 to byte , length * 4
|
||||
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AUDIO_CHANNEL_MASK,
|
||||
AUDIO_ENCODING
|
||||
)
|
||||
if (minBufferSize == 0) {
|
||||
if (bufferSize <= 0) {
|
||||
Log.d(logTag, "get min buffer size fail!")
|
||||
return
|
||||
}
|
||||
audioReader = AudioReader(minBufferSize, 4)
|
||||
audioReader = AudioReader(bufferSize, 4)
|
||||
minBufferSize = bufferSize
|
||||
Log.d(logTag, "init audioData len:$minBufferSize")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder() {
|
||||
checkAudioReader()
|
||||
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
|
||||
try {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
audioRecorder!!.startRecording()
|
||||
audioRecordStat = true
|
||||
audioThread = thread {
|
||||
while (audioRecordStat) {
|
||||
audioReader!!.readSync(audioRecorder!!)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
// let's release here rather than onDestroy to avoid threading issue
|
||||
audioRecorder?.release()
|
||||
audioRecorder = null
|
||||
minBufferSize = 0
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(logTag, "startAudioRecorder fail:$e")
|
||||
private fun releaseRecorder(recorder: AudioRecord) {
|
||||
try {
|
||||
recorder.release()
|
||||
} finally {
|
||||
if (audioRecorder === recorder) {
|
||||
audioRecorder = null
|
||||
}
|
||||
} else {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
|
||||
try {
|
||||
while (audioRecordStat) {
|
||||
reader.readSync(recorder)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
minBufferSize = 0
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
releaseAudioFramePublisher()
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder(): Boolean {
|
||||
val recorder = audioRecorder
|
||||
if (recorder == null) {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
var audioFramePublisherAcquired = false
|
||||
return try {
|
||||
checkAudioReader()
|
||||
val reader = audioReader
|
||||
if (reader == null || minBufferSize == 0) {
|
||||
releaseRecorder(recorder)
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
recorder.startRecording()
|
||||
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
throw IllegalStateException("AudioRecord failed to enter recording state")
|
||||
}
|
||||
audioRecordStat = true
|
||||
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
|
||||
acquireAudioFramePublisher()
|
||||
audioFramePublisherAcquired = true
|
||||
audioThread = captureThread
|
||||
captureThread.start()
|
||||
true
|
||||
} catch (error: Exception) {
|
||||
audioRecordStat = false
|
||||
audioThread = null
|
||||
Log.e(logTag, "startAudioRecorder fail", error)
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
if (audioFramePublisherAcquired) {
|
||||
releaseAudioFramePublisher()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isVoiceCallActive(): Boolean {
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (!isSupportVoiceCall()) {
|
||||
return true
|
||||
}
|
||||
if (isVideoStart()) {
|
||||
switchOutVoiceCall(mediaProjection)
|
||||
}
|
||||
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
|
||||
tryReleaseAudio()
|
||||
return true
|
||||
return switched
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.graphics.Color
|
||||
@@ -150,7 +151,7 @@ class MainService : Service() {
|
||||
if (incomingVoiceCall) {
|
||||
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
|
||||
} else {
|
||||
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
|
||||
if (!switchOutVoiceCall()) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -159,7 +160,7 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
|
||||
if (!switchToVoiceCall()) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -214,17 +215,19 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private val mediaProjectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
private var mediaProjectionCallback: MediaProjection.Callback? = null
|
||||
private var captureRestartPending = false
|
||||
private var captureRestartInVoiceCall = false
|
||||
private val mediaProjectionResultReceiver =
|
||||
object : ResultReceiver(Handler(Looper.getMainLooper())) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
if (resultCode == RES_FAILED) {
|
||||
cancelMediaProjectionRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private var mediaProjectionForegroundService = false
|
||||
private var microphoneForegroundService = false
|
||||
private var surface: Surface? = null
|
||||
private val sendVP9Thread = Executors.newSingleThreadExecutor()
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
@@ -350,8 +353,6 @@ class MainService : Service() {
|
||||
Log.d("whichService", "this service: ${Thread.currentThread()}")
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
|
||||
createForegroundNotification()
|
||||
|
||||
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
|
||||
FFI.startService()
|
||||
}
|
||||
@@ -360,13 +361,7 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
releaseMediaProjection()
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -380,18 +375,21 @@ class MainService : Service() {
|
||||
updateScreenInfo(newConfig.orientation)
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
private fun requestMediaProjection(recovery: Boolean = false) {
|
||||
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
|
||||
action = ACT_REQUEST_MEDIA_PROJECTION
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
if (recovery) {
|
||||
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun releaseMediaProjection() {
|
||||
mediaProjection?.unregisterCallback(mediaProjectionCallback)
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
@Synchronized
|
||||
private fun cancelMediaProjectionRecovery() {
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
@@ -427,15 +425,149 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
private fun releaseMediaProjection() {
|
||||
val projection = mediaProjection
|
||||
val callback = mediaProjectionCallback
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
if (projection != null && callback != null) {
|
||||
projection.unregisterCallback(callback)
|
||||
}
|
||||
projection?.stop()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
|
||||
if (mediaProjection !== stoppedProjection) {
|
||||
return
|
||||
}
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
setMediaProjectionForegroundService(false)
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun replaceMediaProjection(
|
||||
mediaProjectionManager: MediaProjectionManager,
|
||||
resultIntent: Intent,
|
||||
) {
|
||||
val wasCapturing = isStart
|
||||
val restartCapture = wasCapturing || captureRestartPending
|
||||
val restartInVoiceCall = if (wasCapturing) {
|
||||
audioRecordHandle.isVoiceCallActive()
|
||||
} else {
|
||||
captureRestartInVoiceCall
|
||||
}
|
||||
val hadProjection = mediaProjection != null
|
||||
if (!setMediaProjectionForegroundService(true)) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
|
||||
if (projection == null) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
setMediaProjectionForegroundService(false)
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (wasCapturing) {
|
||||
stopCapture()
|
||||
}
|
||||
captureRestartPending = restartCapture
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
val callback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
handleMediaProjectionStopped(projection)
|
||||
}
|
||||
}
|
||||
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
mediaProjectionCallback = callback
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
if (restartCapture) {
|
||||
captureRestartPending = false
|
||||
startCapture(restartInVoiceCall)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
|
||||
if (!setMicrophoneForegroundService(true)) {
|
||||
return false
|
||||
}
|
||||
if (startAudio()) {
|
||||
return true
|
||||
}
|
||||
setMicrophoneForegroundService(false)
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
|
||||
val stopped = stopAudio()
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return stopped && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchToVoiceCall(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.switchToVoiceCall(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchOutVoiceCall(): Boolean {
|
||||
captureRestartInVoiceCall = false
|
||||
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return switched && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallClosed(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
captureRestartInVoiceCall = false
|
||||
return stopMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
fun startCapture(): Boolean {
|
||||
return startCapture(false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startCapture(inVoiceCall: Boolean): Boolean {
|
||||
if (isStart) {
|
||||
return true
|
||||
}
|
||||
@@ -443,25 +575,35 @@ class MainService : Service() {
|
||||
Log.w(logTag, "startCapture fail,mediaProjection is null")
|
||||
return false
|
||||
}
|
||||
captureRestartInVoiceCall = inVoiceCall
|
||||
|
||||
updateScreenInfo(resources.configuration.orientation)
|
||||
Log.d(logTag, "Start Capture")
|
||||
surface = createSurface()
|
||||
|
||||
if (useVP9) {
|
||||
val videoStarted = if (useVP9) {
|
||||
startVP9VideoRecorder(mediaProjection!!)
|
||||
} else {
|
||||
startRawVideoRecorder(mediaProjection!!)
|
||||
}
|
||||
if (!videoStarted) {
|
||||
if (!captureRestartPending) {
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
releaseFailedVideoCapture()
|
||||
return false
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
|
||||
Log.d(logTag, "createAudioRecorder fail")
|
||||
val audioStarted = if (inVoiceCall) {
|
||||
switchToVoiceCall()
|
||||
} else {
|
||||
Log.d(logTag, "audio recorder start")
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
}
|
||||
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
|
||||
}
|
||||
captureRestartInVoiceCall = false
|
||||
checkMediaPermission()
|
||||
_isStart = true
|
||||
FFI.setFrameRawEnable("video",true)
|
||||
@@ -469,9 +611,24 @@ class MainService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun releaseFailedVideoCapture() {
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
videoEncoder?.let {
|
||||
it.signalEndOfInputStream()
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
videoEncoder = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCapture() {
|
||||
Log.d(logTag, "Stop Capture")
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
FFI.setFrameRawEnable("video",false)
|
||||
_isStart = false
|
||||
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
|
||||
@@ -502,8 +659,11 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -519,6 +679,8 @@ class MainService : Service() {
|
||||
}
|
||||
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -541,49 +703,70 @@ class MainService : Service() {
|
||||
return isReady
|
||||
}
|
||||
|
||||
private fun startRawVideoRecorder(mp: MediaProjection) {
|
||||
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
|
||||
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
|
||||
if (surface == null) {
|
||||
val captureSurface = surface
|
||||
if (captureSurface == null) {
|
||||
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
|
||||
return
|
||||
return false
|
||||
}
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
return createOrSetVirtualDisplay(mp, captureSurface)
|
||||
}
|
||||
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection) {
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
|
||||
createMediaCodec()
|
||||
videoEncoder?.let {
|
||||
surface = it.createInputSurface()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
it.setCallback(cb)
|
||||
it.start()
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
val encoder = videoEncoder ?: return false
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
surface = inputSurface
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
encoder.setCallback(cb)
|
||||
encoder.start()
|
||||
return createOrSetVirtualDisplay(mp, inputSurface)
|
||||
}
|
||||
|
||||
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
|
||||
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
|
||||
try {
|
||||
virtualDisplay?.let {
|
||||
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
it.setSurface(s)
|
||||
} ?: let {
|
||||
virtualDisplay = mp.createVirtualDisplay(
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
|
||||
return try {
|
||||
val existingDisplay = virtualDisplay
|
||||
if (existingDisplay != null) {
|
||||
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
existingDisplay.setSurface(s)
|
||||
true
|
||||
} else {
|
||||
val display = mp.createVirtualDisplay(
|
||||
"RustDeskVD",
|
||||
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
s, null, null
|
||||
)
|
||||
if (display == null) {
|
||||
Log.e(logTag, "createOrSetVirtualDisplay failed")
|
||||
handleVirtualDisplayFailure()
|
||||
} else {
|
||||
virtualDisplay = display
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
|
||||
// This initiates a prompt dialog for the user to confirm screen projection.
|
||||
requestMediaProjection()
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
|
||||
handleVirtualDisplayFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVirtualDisplayFailure(): Boolean {
|
||||
captureRestartPending = true
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
setMediaProjectionForegroundService(false)
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
requestMediaProjection(true)
|
||||
return false
|
||||
}
|
||||
|
||||
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
|
||||
@@ -674,7 +857,63 @@ class MainService : Service() {
|
||||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
|
||||
} else {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun foregroundServiceType(): Int {
|
||||
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
if (mediaProjectionForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
}
|
||||
return serviceType
|
||||
}
|
||||
|
||||
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
|
||||
}
|
||||
|
||||
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
|
||||
}
|
||||
|
||||
private fun updateForegroundServiceTypes(
|
||||
mediaProjectionEnabled: Boolean,
|
||||
microphoneEnabled: Boolean,
|
||||
): Boolean {
|
||||
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
|
||||
microphoneForegroundService == microphoneEnabled) {
|
||||
return true
|
||||
}
|
||||
val previousMediaProjection = mediaProjectionForegroundService
|
||||
val previousMicrophone = microphoneForegroundService
|
||||
mediaProjectionForegroundService = mediaProjectionEnabled
|
||||
microphoneForegroundService = microphoneEnabled
|
||||
return try {
|
||||
createForegroundNotification()
|
||||
true
|
||||
} catch (error: SecurityException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
} catch (error: IllegalStateException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginRequestNotification(
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.util.Log
|
||||
|
||||
class PermissionRequestTransparentActivity: Activity() {
|
||||
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
launchService(data)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
val resultReceiver =
|
||||
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
|
||||
if (resultReceiver != null) {
|
||||
resultReceiver.send(RES_FAILED, null)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
|
||||
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
|
||||
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
|
||||
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
|
||||
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
|
||||
// Activity requestCode
|
||||
@@ -164,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
|
||||
fun translate(input: String): String {
|
||||
Log.d("common", "translate:$LOCAL_NAME")
|
||||
return FFI.translateLocale(LOCAL_NAME, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">RustDesk</string>
|
||||
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
|
||||
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
def legacyPluginNamespaces = [
|
||||
external_path: 'com.pinciat.external_path',
|
||||
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
|
||||
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
|
||||
sqflite: 'com.tekartik.sqflite',
|
||||
uni_links: 'name.avioli.unilinks',
|
||||
]
|
||||
|
||||
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
def java8KotlinJvmTargets = [
|
||||
app: java8JvmTarget,
|
||||
external_path: java8JvmTarget,
|
||||
qr_code_scanner: java8JvmTarget,
|
||||
]
|
||||
|
||||
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
|
||||
project.plugins.withId('kotlin-android') {
|
||||
project.tasks.configureEach { task ->
|
||||
if (!task.hasProperty('kotlinOptions')) {
|
||||
return
|
||||
}
|
||||
task.kotlinOptions.jvmTarget = kotlinJvmTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +35,16 @@ allprojects {
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
def legacyNamespace = legacyPluginNamespaces[project.name]
|
||||
if (legacyNamespace != null) {
|
||||
project.plugins.withId('com.android.library') {
|
||||
project.android.namespace = legacyNamespace
|
||||
}
|
||||
}
|
||||
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
|
||||
if (kotlinJvmTarget != null) {
|
||||
configureKotlinJvmTarget(project, kotlinJvmTarget)
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
|
||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -244,11 +244,38 @@ List<(String, String)> otherDefaultSettings() {
|
||||
kKeyUseAllMyDisplaysForTheRemoteSession
|
||||
),
|
||||
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
|
||||
(
|
||||
'Allow terminal apps to copy to clipboard',
|
||||
kOptionAllowTerminalClipboardWrite
|
||||
),
|
||||
];
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
String getOtherDefaultSettingOption(String key) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainGetLocalOption(key: key);
|
||||
}
|
||||
return bind.mainGetUserDefaultOption(key: key);
|
||||
}
|
||||
|
||||
Future<void> setOtherDefaultSettingOption(String key, String value) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainSetLocalOption(
|
||||
key: key,
|
||||
value: value == kTerminalClipboardWriteAllowed
|
||||
? kTerminalClipboardWriteAllowed
|
||||
: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
}
|
||||
return bind.mainSetUserDefaultOption(key: key, value: value);
|
||||
}
|
||||
|
||||
bool isOtherDefaultSettingReadOnly(String key) =>
|
||||
isOptionFixed(key) ||
|
||||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
|
||||
|
||||
class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
|
||||
@@ -115,6 +115,11 @@ const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTerminal = "enable-terminal";
|
||||
const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionAllowTerminalClipboardWrite =
|
||||
"allow-terminal-clipboard-write";
|
||||
const String kTerminalClipboardWriteUnconfigured = "";
|
||||
const String kTerminalClipboardWriteAllowed = "Y";
|
||||
const String kTerminalClipboardWriteDenied = "N";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
@@ -159,6 +164,7 @@ const String kOptionPeerTabVisible = "peer-tab-visible";
|
||||
const String kOptionPeerCardUiType = "peer-card-ui-type";
|
||||
const String kOptionCurrentAbName = "current-ab-name";
|
||||
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
|
||||
const String kOptionEnablePortForwardMux = "enable-port-forward-mux";
|
||||
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
|
||||
const String kOptionEnableCheckUpdate = "enable-check-update";
|
||||
const String kOptionAllowAutoUpdate = "allow-auto-update";
|
||||
@@ -168,6 +174,8 @@ const String kOptionDirectxCapture = "enable-directx-capture";
|
||||
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
|
||||
@@ -330,12 +330,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
void onConnect(
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTerminal = false}) {
|
||||
bool isTerminal = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTerminal: isTerminal);
|
||||
isTerminal: isTerminal,
|
||||
isTcpTunneling: isTcpTunneling);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -568,6 +570,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
'${translate('Terminal')} (beta)',
|
||||
() => onConnect(isTerminal: true)
|
||||
),
|
||||
// `connect` routes this through the
|
||||
// desktop path only; the peer card gates
|
||||
// it the same way.
|
||||
if (isDesktop)
|
||||
(
|
||||
'TCP tunneling',
|
||||
() => onConnect(isTcpTunneling: true)
|
||||
),
|
||||
]
|
||||
.map((e) => MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) =>
|
||||
|
||||
@@ -509,6 +509,15 @@ class _GeneralState extends State<_General> {
|
||||
kOptionOpenNewConnInTabs,
|
||||
isServer: false,
|
||||
),
|
||||
Tooltip(
|
||||
message: translate('port-forward-mux-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Reuse one connection for port forwarding',
|
||||
kOptionEnablePortForwardMux,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local
|
||||
if (isLinux)
|
||||
Tooltip(
|
||||
@@ -575,6 +584,15 @@ class _GeneralState extends State<_General> {
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Sync clipboard between sessions',
|
||||
kOptionAllowSyncClipboardBetweenSessions,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -2071,14 +2089,13 @@ class _DisplayState extends State<_Display> {
|
||||
}
|
||||
|
||||
Widget otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
onChanged(bool b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key,
|
||||
value: b
|
||||
? 'Y'
|
||||
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
|
||||
required this.tabKey,
|
||||
this.forceRelay,
|
||||
this.connToken,
|
||||
this.onClipboardWriteBlocked,
|
||||
this.onClipboardWriteSucceeded,
|
||||
}) : super(key: key);
|
||||
final String id;
|
||||
final String? password;
|
||||
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final ValueChanged<String>? onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
@@ -10,6 +11,8 @@ import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
@@ -19,6 +22,12 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
import '../widgets/popup_menu.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
typedef _TerminalClipboardSource = ({
|
||||
String peerId,
|
||||
int terminalId,
|
||||
String tabKey,
|
||||
});
|
||||
|
||||
class TerminalTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
@@ -30,6 +39,18 @@ class TerminalTabPage extends StatefulWidget {
|
||||
|
||||
class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
DesktopTabController get tabController => Get.find<DesktopTabController>();
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
static const IconData selectedIcon = Icons.terminal;
|
||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||
@@ -38,6 +59,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final Set<String> _closingTabs = {};
|
||||
// When true, all session cleanup should persist (window-level close in progress)
|
||||
bool _windowClosing = false;
|
||||
CancelFunc? _terminalClipboardNoticeCancel;
|
||||
final _terminalClipboardNotice =
|
||||
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
|
||||
|
||||
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||
@@ -45,7 +69,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
WindowController.fromWindowId(windowId())
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onRemoved = (_, id) {
|
||||
_closeTerminalClipboardNoticeForTab(id);
|
||||
onRemoveId(id);
|
||||
};
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
@@ -70,6 +97,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||
final tabLabel =
|
||||
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||
final clipboardSource = (
|
||||
peerId: peerId,
|
||||
terminalId: terminalId,
|
||||
tabKey: tabKey,
|
||||
);
|
||||
return TabInfo(
|
||||
key: tabKey,
|
||||
label: tabLabel,
|
||||
@@ -86,10 +118,169 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
tabController: tabController,
|
||||
forceRelay: forceRelay,
|
||||
connToken: connToken,
|
||||
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
|
||||
? (text) => _handleTerminalClipboardWriteBlocked(
|
||||
clipboardSource,
|
||||
text,
|
||||
)
|
||||
: null,
|
||||
onClipboardWriteSucceeded: (_) {
|
||||
_handleTerminalClipboardWriteSucceeded(clipboardSource);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(
|
||||
_TerminalClipboardSource source,
|
||||
String clipboardText,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: source,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) {
|
||||
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
|
||||
duration: null,
|
||||
enableSlideOff: false,
|
||||
onlyOne: true,
|
||||
onClose: _handleTerminalClipboardNoticeClosed,
|
||||
toastBuilder: (_) => AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNoticeClosed() {
|
||||
_terminalClipboardNoticeCancel = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
|
||||
bool _canWriteTerminalClipboard(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
if (!_canHandleTerminalClipboardWriteRequest) return false;
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
|
||||
return ffi != null &&
|
||||
!ffi.closed &&
|
||||
ffi.ffiModel.permissions['clipboard'] != false &&
|
||||
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
|
||||
ffi.terminalModels.containsKey(source.terminalId);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
final request = _terminalClipboardNotice.currentForSource(source);
|
||||
if (request == null) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) async {
|
||||
final source = request.source;
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard(source),
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNoticeForTab(String tabKey) {
|
||||
final current = _terminalClipboardNotice.current;
|
||||
if (current?.source.tabKey != tabKey) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final cancel = _terminalClipboardNoticeCancel;
|
||||
if (cancel == null) {
|
||||
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -147,6 +338,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||
// Keep the cleanup target lookup below synchronous before its first await:
|
||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
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.
|
||||
@@ -357,6 +550,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1269,16 +1269,18 @@ class __DisplayPageState extends State<_DisplayPage> {
|
||||
}
|
||||
|
||||
SettingsTile otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
return SettingsTile.switchTile(
|
||||
initialValue: value,
|
||||
title: Text(translate(label)),
|
||||
onToggle: isOptFixed
|
||||
? null
|
||||
: (b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key, value: b ? 'Y' : defaultOptionNo);
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : defaultOptionNo,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
|
||||
@@ -19,6 +20,49 @@ import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
const _terminalBackgroundOpacity = 0.7;
|
||||
|
||||
Widget _buildTerminalViewForPlatform({
|
||||
required bool reportMouseInput,
|
||||
required bool reportTouchInput,
|
||||
required Terminal terminal,
|
||||
required TerminalController controller,
|
||||
required TerminalStyle textStyle,
|
||||
required EdgeInsets padding,
|
||||
required bool deleteDetection,
|
||||
required Map<ShortcutActivator, Intent>? shortcuts,
|
||||
required FocusOnKeyEventCallback onKeyEvent,
|
||||
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
|
||||
}) {
|
||||
if (reportMouseInput || reportTouchInput) {
|
||||
return TerminalMouseInteraction(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
reportTouchInput: reportTouchInput,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
return TerminalView(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
Key? key,
|
||||
@@ -41,6 +85,19 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
@@ -57,6 +114,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
|
||||
_terminalClipboardNoticeController;
|
||||
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -89,6 +149,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
if (_canHandleTerminalClipboardWriteRequest) {
|
||||
_terminalModel.onClipboardWriteBlocked =
|
||||
_handleTerminalClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded =
|
||||
_handleTerminalClipboardWriteSucceeded;
|
||||
}
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
@@ -134,12 +200,144 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: widget.terminalId,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: (_) => _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) {
|
||||
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
|
||||
MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
_terminalClipboardNoticeController = controller;
|
||||
unawaited(controller.closed.then<void>((_) {
|
||||
if (identical(_terminalClipboardNoticeController, controller)) {
|
||||
_terminalClipboardNoticeController = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
bool get _canWriteTerminalClipboard =>
|
||||
_canHandleTerminalClipboardWriteRequest &&
|
||||
!_ffi.closed &&
|
||||
_ffi.ffiModel.permissions['clipboard'] != false;
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(String _) {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) async {
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard,
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final controller = _terminalClipboardNoticeController;
|
||||
if (controller == null) {
|
||||
debugPrint('[TerminalPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeController?.close();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
@@ -234,12 +432,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
return _buildTerminalViewForPlatform(
|
||||
reportMouseInput: isWebDesktop || isAndroid,
|
||||
reportTouchInput: isIOS,
|
||||
terminal: _terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
// The following comment is from xterm.dart source code:
|
||||
// Workaround to detect delete key for platforms and IMEs that do not
|
||||
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||
|
||||
@@ -1,7 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
enum TerminalClipboardWritePermission { denied, unconfigured, allowed }
|
||||
|
||||
class RustDeskTerminal extends Terminal {
|
||||
RustDeskTerminal({super.maxLines});
|
||||
RustDeskTerminal({
|
||||
super.maxLines,
|
||||
required TerminalClipboardWritePermission Function()
|
||||
clipboardWritePermission,
|
||||
required Future<bool> Function(String) onClipboardWrite,
|
||||
ValueChanged<String>? onClipboardWriteBlocked,
|
||||
ValueChanged<String>? onClipboardWriteSucceeded,
|
||||
}) : _clipboardWritePermission = clipboardWritePermission,
|
||||
_onClipboardWrite = onClipboardWrite,
|
||||
_onClipboardWriteBlocked = onClipboardWriteBlocked,
|
||||
_onClipboardWriteSucceeded = onClipboardWriteSucceeded {
|
||||
onPrivateOSC = _handlePrivateOsc;
|
||||
}
|
||||
|
||||
static const _clipboardOscCode = '52';
|
||||
static const _systemClipboardSelection = 'c';
|
||||
// Match the terminal helper's existing payload safety ceiling.
|
||||
static const _maxClipboardWriteBytes = 16 * 1024 * 1024;
|
||||
static const _base64InputBytesPerBlock = 3;
|
||||
static const _base64EncodedCharsPerBlock = 4;
|
||||
static final _osc52Selection = RegExp(r'^[cpqs0-7]*$');
|
||||
final TerminalClipboardWritePermission Function() _clipboardWritePermission;
|
||||
final Future<bool> Function(String) _onClipboardWrite;
|
||||
final ValueChanged<String>? _onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? _onClipboardWriteSucceeded;
|
||||
|
||||
bool get isClipboardWriteAllowed =>
|
||||
_clipboardWritePermission() == TerminalClipboardWritePermission.allowed;
|
||||
|
||||
void _handlePrivateOsc(String code, List<String> args) {
|
||||
if (code != _clipboardOscCode) return;
|
||||
if (args.length != 2 || !_osc52Selection.hasMatch(args.first)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 command');
|
||||
return;
|
||||
}
|
||||
if (args.last == '?') {
|
||||
debugPrint('[RustDeskTerminal] Rejected OSC 52 clipboard query');
|
||||
return;
|
||||
}
|
||||
final permission = _clipboardWritePermission();
|
||||
if (permission == TerminalClipboardWritePermission.denied) {
|
||||
debugPrint('[RustDeskTerminal] Rejected unauthorized OSC 52 write');
|
||||
return;
|
||||
}
|
||||
final selection = args.first;
|
||||
if (selection.isNotEmpty &&
|
||||
!selection.contains(_systemClipboardSelection)) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selection');
|
||||
return;
|
||||
}
|
||||
if (selection.replaceAll(_systemClipboardSelection, '').isNotEmpty) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selections');
|
||||
}
|
||||
final text = _decodeClipboardPayload(args.last);
|
||||
if (text == null) return;
|
||||
if (permission == TerminalClipboardWritePermission.unconfigured) {
|
||||
debugPrint('[RustDeskTerminal] Blocked OSC 52 write pending consent');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
return;
|
||||
}
|
||||
unawaited(_writeClipboard(text));
|
||||
}
|
||||
|
||||
Future<void> _writeClipboard(String text) async {
|
||||
final succeeded = await _onClipboardWrite(text);
|
||||
if (succeeded) {
|
||||
_onClipboardWriteSucceeded?.call(text);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'[RustDeskTerminal] OSC 52 clipboard write requires interaction');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
}
|
||||
|
||||
String? _decodeClipboardPayload(String payload) {
|
||||
if (payload.length > _maxBase64EncodedLength(_maxClipboardWriteBytes)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = base64.decode(payload);
|
||||
if (bytes.length > _maxClipboardWriteBytes) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
return utf8.decode(bytes);
|
||||
} on FormatException {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static int _maxBase64EncodedLength(int maxBytes) =>
|
||||
((maxBytes + _base64InputBytesPerBlock - 1) ~/
|
||||
_base64InputBytesPerBlock) *
|
||||
_base64EncodedCharsPerBlock;
|
||||
|
||||
@override
|
||||
void eraseScrollbackOnly() {
|
||||
|
||||
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const _writeTerminalClipboardCommand = 'write_terminal_clipboard';
|
||||
|
||||
@JS('setByName')
|
||||
external JSPromise<JSBoolean> _setByName(
|
||||
JSString name,
|
||||
JSString value,
|
||||
JSBoolean userInitiated,
|
||||
);
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _setByName(
|
||||
_writeTerminalClipboardCommand.toJS,
|
||||
text.toJS,
|
||||
userInitiated.toJS,
|
||||
).toDart;
|
||||
return result.toDart;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write Web clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,130 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'terminal_clipboard_writer.dart'
|
||||
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
|
||||
|
||||
const _controlShiftVPasteShortcut = SingleActivator(
|
||||
LogicalKeyboardKey.keyV,
|
||||
control: true,
|
||||
shift: true,
|
||||
);
|
||||
|
||||
Future<void> writeTerminalClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
typedef TerminalClipboardWriter = Future<bool> Function(
|
||||
String text, {
|
||||
required bool userInitiated,
|
||||
});
|
||||
|
||||
class TerminalClipboardNoticeRequest<T> {
|
||||
const TerminalClipboardNoticeRequest({
|
||||
required this.source,
|
||||
required this.text,
|
||||
required this.persistAllowed,
|
||||
});
|
||||
|
||||
final T source;
|
||||
final String text;
|
||||
final bool persistAllowed;
|
||||
|
||||
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
|
||||
|
||||
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
|
||||
}
|
||||
|
||||
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
|
||||
|
||||
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
|
||||
TerminalClipboardNoticeRequest<T>? _current;
|
||||
bool _noticeVisible = false;
|
||||
bool _actionInProgress = false;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? get current => _current;
|
||||
bool get canClaimAction =>
|
||||
_noticeVisible && !_actionInProgress && _current != null;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
|
||||
final current = _current;
|
||||
if (current == null || current.source != source) return null;
|
||||
return current;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? recordBlocked({
|
||||
required T source,
|
||||
required String text,
|
||||
required String option,
|
||||
required bool Function(T source) canWrite,
|
||||
}) {
|
||||
if (!canWrite(source)) return null;
|
||||
final requestAllowsPersistence =
|
||||
option == kTerminalClipboardWriteUnconfigured;
|
||||
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
|
||||
return null;
|
||||
}
|
||||
if (_noticeVisible && _actionInProgress) return null;
|
||||
final wasVisible = _noticeVisible;
|
||||
final persistAllowed =
|
||||
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
|
||||
final request = TerminalClipboardNoticeRequest(
|
||||
source: source,
|
||||
text: text,
|
||||
persistAllowed: persistAllowed ?? requestAllowsPersistence,
|
||||
);
|
||||
_current = request;
|
||||
if (wasVisible) return null;
|
||||
_noticeVisible = true;
|
||||
return request;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
|
||||
if (!canClaimAction) return null;
|
||||
final current = _current;
|
||||
if (current == null) return null;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return current;
|
||||
}
|
||||
|
||||
void releaseAction() {
|
||||
if (!_actionInProgress) return;
|
||||
_actionInProgress = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool beginClose() {
|
||||
if (!_noticeVisible) return false;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void noticeClosed() => clear();
|
||||
|
||||
void clear() {
|
||||
_current = null;
|
||||
_noticeVisible = false;
|
||||
_actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> writeTerminalClipboard(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) =>
|
||||
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
|
||||
|
||||
Future<bool> completeTerminalClipboardWrite({
|
||||
required String clipboardText,
|
||||
required bool Function() canWrite,
|
||||
required TerminalClipboardWriter writeClipboard,
|
||||
Future<void> Function()? persistAllowed,
|
||||
}) async {
|
||||
if (!canWrite()) return false;
|
||||
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
|
||||
await persistAllowed?.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
@@ -68,7 +178,7 @@ FocusOnKeyEventCallback terminalCopyHandler(
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
unawaited(writeTerminalClipboard(text, userInitiated: true));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,38 @@ import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_handler.dart';
|
||||
|
||||
bool canConfigureTerminalClipboardPermission({
|
||||
required bool settingsDisabled,
|
||||
required bool optionFixed,
|
||||
}) =>
|
||||
!settingsDisabled && !optionFixed;
|
||||
|
||||
bool canHandleTerminalClipboardWriteRequest({
|
||||
required String localOption,
|
||||
required bool canConfigurePermission,
|
||||
}) =>
|
||||
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
|
||||
|
||||
TerminalClipboardWritePermission terminalClipboardWritePermission(
|
||||
String localOption, {
|
||||
required bool remoteClipboardEnabled,
|
||||
bool canRequestConsent = true,
|
||||
}) {
|
||||
if (!remoteClipboardEnabled) {
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteAllowed) {
|
||||
return TerminalClipboardWritePermission.allowed;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
|
||||
return TerminalClipboardWritePermission.unconfigured;
|
||||
}
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
final FFI parent;
|
||||
@@ -62,6 +92,9 @@ class TerminalModel with ChangeNotifier {
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
ValueChanged<String>? onClipboardWriteBlocked;
|
||||
ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// xterm can complete asynchronous input after the Flutter page has gone
|
||||
// away. Stop before reading or clearing widget-owned modifier state.
|
||||
@@ -130,7 +163,19 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal = RustDeskTerminal(
|
||||
maxLines: 10000,
|
||||
onClipboardWrite: writeTerminalClipboard,
|
||||
clipboardWritePermission: () => terminalClipboardWritePermission(
|
||||
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
|
||||
remoteClipboardEnabled:
|
||||
parent.ffiModel.permissions['clipboard'] != false,
|
||||
canRequestConsent: onClipboardWriteBlocked != null,
|
||||
),
|
||||
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
|
||||
onClipboardWriteSucceeded: (text) =>
|
||||
onClipboardWriteSucceeded?.call(text),
|
||||
);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminalController = TerminalController();
|
||||
|
||||
@@ -593,6 +638,8 @@ class TerminalModel with ChangeNotifier {
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
onClipboardWriteBlocked = null;
|
||||
onClipboardWriteSucceeded = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
@@ -62,13 +62,17 @@ class TerminalMouseDragReporter {
|
||||
var _ownsControllerSuspension = false;
|
||||
var _releasePending = false;
|
||||
var _reporting = false;
|
||||
var _dragged = false;
|
||||
|
||||
bool handleDown(
|
||||
PointerDownEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
|
||||
TerminalViewState? terminalView, {
|
||||
bool reportTouchInput = false,
|
||||
bool deferReport = false,
|
||||
}) {
|
||||
if (!_isPrimaryPointer(event, reportTouchInput) ||
|
||||
!_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
if (terminalView == null || terminalView.widget.readOnly) return false;
|
||||
@@ -83,14 +87,33 @@ class TerminalMouseDragReporter {
|
||||
_pointerId = event.pointer;
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = true;
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
_releasePending = !deferReport;
|
||||
_reporting = !deferReport;
|
||||
_dragged = false;
|
||||
controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
if (!deferReport) {
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool activateDeferredDown(Terminal terminal) {
|
||||
if (_pointerId == null ||
|
||||
_controller == null ||
|
||||
_releasePending ||
|
||||
!_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
_clearSelection(_controller);
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
_report(terminal.mouseReportMode, _lastReportedPosition),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -98,26 +121,36 @@ class TerminalMouseDragReporter {
|
||||
bool handleMove(
|
||||
PointerMoveEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
TerminalViewState? terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView == null) {
|
||||
onCancel?.call();
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
final reportsDrag = _reportsDrag(terminal.mouseMode);
|
||||
if (!_isPrimaryMouse(event)) {
|
||||
if (!_hasPrimaryButton(event)) {
|
||||
if (_releasePending && reportsDrag) {
|
||||
_reportRelease(
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
if (!_reporting || !reportsDrag) {
|
||||
if (!reportsDrag) _releasePending = false;
|
||||
if (!reportsDrag && _releasePending) {
|
||||
_releasePending = false;
|
||||
onCancel?.call();
|
||||
}
|
||||
_reporting = false;
|
||||
// Keep ownership until the matching end event to suppress local selection.
|
||||
final controller = _controller;
|
||||
@@ -126,7 +159,7 @@ class TerminalMouseDragReporter {
|
||||
}
|
||||
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
_recordPosition(position);
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position, motion: true),
|
||||
);
|
||||
@@ -138,16 +171,22 @@ class TerminalMouseDragReporter {
|
||||
bool handleEnd(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
TerminalViewState? terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView != null &&
|
||||
_releasePending &&
|
||||
_reportsDrag(terminal.mouseMode)) {
|
||||
_reportRelease(
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
_clearSelection(_controller);
|
||||
final controller = _controller;
|
||||
@@ -172,6 +211,7 @@ class TerminalMouseDragReporter {
|
||||
_ownsControllerSuspension = false;
|
||||
_releasePending = false;
|
||||
_reporting = false;
|
||||
_dragged = false;
|
||||
}
|
||||
|
||||
void updateController(TerminalController controller) {
|
||||
@@ -203,6 +243,24 @@ class TerminalMouseDragReporter {
|
||||
);
|
||||
}
|
||||
|
||||
void _finishRelease(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
}) {
|
||||
final position =
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition;
|
||||
if (_reporting) _recordPosition(position);
|
||||
beforeRelease?.call(_dragged);
|
||||
_reportRelease(terminal, position);
|
||||
}
|
||||
|
||||
void _recordPosition(CellOffset position) {
|
||||
_dragged = _dragged || position != _lastReportedPosition;
|
||||
_lastReportedPosition = position;
|
||||
}
|
||||
|
||||
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
@@ -210,9 +268,13 @@ class TerminalMouseDragReporter {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPrimaryMouse(PointerEvent event) =>
|
||||
event.kind == PointerDeviceKind.mouse &&
|
||||
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
|
||||
bool _isPrimaryPointer(PointerEvent event, bool reportTouchInput) =>
|
||||
(event.kind == PointerDeviceKind.mouse ||
|
||||
reportTouchInput && event.kind == PointerDeviceKind.touch) &&
|
||||
_hasPrimaryButton(event);
|
||||
|
||||
bool _hasPrimaryButton(PointerEvent event) =>
|
||||
(event.buttons & kPrimaryButton) == kPrimaryButton;
|
||||
|
||||
bool _reportsDrag(MouseMode mode) =>
|
||||
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_drag_reporter.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({this.positionProvider});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
part 'terminal_mouse_handler_input.dart';
|
||||
part 'terminal_web_clipboard_gesture.dart';
|
||||
|
||||
class TerminalMouseInteraction extends StatefulWidget {
|
||||
const TerminalMouseInteraction(
|
||||
@@ -47,6 +19,12 @@ class TerminalMouseInteraction extends StatefulWidget {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.textStyle = const TerminalStyle(),
|
||||
this.deleteDetection = false,
|
||||
this.reportTouchInput = false,
|
||||
this.shortcuts,
|
||||
this.onKeyEvent,
|
||||
this.backgroundOpacity = 1,
|
||||
this.padding,
|
||||
this.onSecondaryTapDown,
|
||||
@@ -55,6 +33,12 @@ class TerminalMouseInteraction extends StatefulWidget {
|
||||
final Terminal terminal;
|
||||
final TerminalController controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final TerminalStyle textStyle;
|
||||
final bool deleteDetection;
|
||||
final bool reportTouchInput;
|
||||
final Map<ShortcutActivator, Intent>? shortcuts;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
final double backgroundOpacity;
|
||||
final EdgeInsets? padding;
|
||||
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
|
||||
@@ -81,8 +65,13 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
Buffer? _selectionBuffer;
|
||||
int? _selectionPointerId;
|
||||
Timer? _selectionScrollTimer;
|
||||
Timer? _pendingTouchMouseTimer;
|
||||
PointerDownEvent? _pendingTouchMouseDown;
|
||||
var _selectionHasScrolled = false;
|
||||
var _scrollDirection = _noScroll;
|
||||
// xterm can finish its tap callbacks after the raw drag was reported.
|
||||
var _suppressXtermLeftButton = false;
|
||||
var _terminalClipboardGesturePrepared = false;
|
||||
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
|
||||
|
||||
@override
|
||||
@@ -90,6 +79,7 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
super.initState();
|
||||
_mouseHandler = WheelButtonFixMouseHandler(
|
||||
positionProvider: _cellAtPointer,
|
||||
suppressLeftButton: kIsWeb ? _consumeXtermLeftButtonSuppression : null,
|
||||
);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
@@ -100,10 +90,15 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
|
||||
final controllerChanged =
|
||||
!identical(oldWidget.controller, widget.controller);
|
||||
final touchInputChanged =
|
||||
oldWidget.reportTouchInput != widget.reportTouchInput;
|
||||
if (!terminalChanged && !controllerChanged && !touchInputChanged) return;
|
||||
_cancelPendingTouchMouseDrag();
|
||||
if (!terminalChanged && !controllerChanged) return;
|
||||
if (controllerChanged && !terminalChanged) {
|
||||
_mouseDrag.updateController(widget.controller);
|
||||
} else {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
@@ -123,46 +118,18 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
}
|
||||
}
|
||||
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
|
||||
if (_handlePendingTouchMove(event)) return;
|
||||
if (_mouseDrag.handleMove(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: _finishTerminalClipboardWrite,
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (event.pointer != _selectionPointerId) return;
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
@@ -241,8 +208,28 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
|
||||
void _handlePointerEnd(PointerEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
|
||||
event.pointer != _selectionPointerId) return;
|
||||
final pendingTouch = _pendingTouchMouseDown;
|
||||
if (pendingTouch != null && pendingTouch.pointer == event.pointer) {
|
||||
final movedBeyondSlop =
|
||||
(event.position - pendingTouch.position).distance > kTouchSlop;
|
||||
if (event is PointerUpEvent && !movedBeyondSlop) {
|
||||
_activatePendingTouchMouseDrag(cancelOnFailure: false);
|
||||
} else {
|
||||
_takePendingTouchMouseDrag(pointer: event.pointer);
|
||||
}
|
||||
}
|
||||
final handledByMouseDrag = _mouseDrag.handleEnd(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: event is PointerUpEvent
|
||||
? _finishTerminalClipboardWrite
|
||||
: (_) => _cancelTerminalClipboardWrite(),
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
);
|
||||
if (!handledByMouseDrag && event.pointer != _selectionPointerId) {
|
||||
return;
|
||||
}
|
||||
if (_selectionHasScrolled) _scrollSelection(scroll: false);
|
||||
_clearSelectionDrag();
|
||||
}
|
||||
@@ -265,6 +252,8 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_cancelPendingTouchMouseDrag();
|
||||
_mouseDrag.cancel();
|
||||
_clearSelectionDrag();
|
||||
_restoreMouseHandler(widget.terminal);
|
||||
@@ -290,10 +279,14 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
controller: widget.controller,
|
||||
scrollController: _scrollController,
|
||||
focusNode: widget.focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
textStyle: widget.textStyle,
|
||||
deleteDetection: widget.deleteDetection,
|
||||
backgroundOpacity: widget.backgroundOpacity,
|
||||
padding: widget.padding,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
|
||||
shortcuts: widget.shortcuts ?? platformTerminalShortcuts(),
|
||||
onKeyEvent: widget.onKeyEvent ??
|
||||
terminalCopyHandler(widget.terminal, widget.controller),
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
),
|
||||
);
|
||||
|
||||
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({
|
||||
this.positionProvider,
|
||||
this.suppressLeftButton,
|
||||
});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
final bool Function(TerminalMouseButtonState)? suppressLeftButton;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
if (event.button == TerminalMouseButton.left &&
|
||||
suppressLeftButton?.call(event.buttonState) == true) {
|
||||
return null;
|
||||
}
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _TerminalMouseInput on _TerminalMouseInteractionState {
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
_suppressXtermLeftButton = false;
|
||||
if (_startPendingTouchMouseDrag(event)) return;
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
if (kIsWeb) _suppressXtermLeftButton = true;
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
bool _startPendingTouchMouseDrag(PointerDownEvent event) {
|
||||
if (!widget.reportTouchInput ||
|
||||
event.kind != PointerDeviceKind.touch ||
|
||||
!_mouseDrag.handleDown(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
reportTouchInput: true,
|
||||
deferReport: true,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_pendingTouchMouseDown = event;
|
||||
_pendingTouchMouseTimer = Timer(
|
||||
kLongPressTimeout,
|
||||
_activatePendingTouchMouseDrag,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _activatePendingTouchMouseDrag({
|
||||
bool cancelOnFailure = true,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag() == null) return false;
|
||||
if (_mouseDrag.activateDeferredDown(widget.terminal)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
_clearSelectionDrag();
|
||||
return true;
|
||||
}
|
||||
if (cancelOnFailure) _mouseDrag.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
PointerDownEvent? _takePendingTouchMouseDrag({int? pointer}) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pointer != null && pointer != pending.pointer) {
|
||||
return null;
|
||||
}
|
||||
_pendingTouchMouseTimer?.cancel();
|
||||
_pendingTouchMouseTimer = null;
|
||||
_pendingTouchMouseDown = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void _cancelPendingTouchMouseDrag({
|
||||
int? pointer,
|
||||
bool deferCancel = false,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag(pointer: pointer) == null) return;
|
||||
if (deferCancel) {
|
||||
scheduleMicrotask(_mouseDrag.cancel);
|
||||
} else {
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
bool _handlePendingTouchMove(PointerMoveEvent event) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pending.pointer != event.pointer) return false;
|
||||
if ((event.position - pending.position).distance > kTouchSlop) {
|
||||
_cancelPendingTouchMouseDrag(
|
||||
pointer: event.pointer,
|
||||
deferCancel: true,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeXtermLeftButtonSuppression(TerminalMouseButtonState state) {
|
||||
final suppress = _suppressXtermLeftButton;
|
||||
if (state == TerminalMouseButtonState.up) {
|
||||
_suppressXtermLeftButton = false;
|
||||
}
|
||||
return suppress;
|
||||
}
|
||||
}
|
||||
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
const _prepareTerminalClipboardCommand = 'prepare_terminal_clipboard';
|
||||
const _finishTerminalClipboardCommand = 'finish_terminal_clipboard';
|
||||
const _cancelTerminalClipboardCommand = 'cancel_terminal_clipboard';
|
||||
|
||||
extension _TerminalWebClipboardGesture on _TerminalMouseInteractionState {
|
||||
void _prepareTerminalClipboardWrite() {
|
||||
if (!kIsWeb) return;
|
||||
_cancelTerminalClipboardWrite();
|
||||
final terminal = widget.terminal;
|
||||
if (terminal is! RustDeskTerminal || !terminal.isClipboardWriteAllowed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ffiSetByName(_prepareTerminalClipboardCommand);
|
||||
_terminalClipboardGesturePrepared = true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to prepare Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _finishTerminalClipboardWrite(bool responseExpected) {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(
|
||||
_finishTerminalClipboardCommand,
|
||||
responseExpected ? 'true' : 'false',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to finish Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelTerminalClipboardWrite() {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _discardPendingTerminalClipboardWrites() {
|
||||
_cancelTerminalClipboardWrite();
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _sendTerminalClipboardCancel() {
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(_cancelTerminalClipboardCommand);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to cancel Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.9+67
|
||||
version: 1.5.0+68
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
|
||||
Submodule libs/hbb_common updated: b2b1ac453d...f94e3fef6c
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -12,7 +12,7 @@ build = "build.rs"
|
||||
brotli = "3.4"
|
||||
dirs = "5.0"
|
||||
md5 = "0.7"
|
||||
winapi = { version = "0.3", features = ["winbase"] }
|
||||
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
|
||||
@@ -15,15 +15,29 @@ encoding = 'utf-8'
|
||||
# output: {path: (compressed_data, file_md5)}
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level) -> dict:
|
||||
def normalize(path: str) -> str:
|
||||
path = path.replace('\\', '/')
|
||||
while path.startswith('./'):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
|
||||
res: dict = dict()
|
||||
curdir = os.curdir
|
||||
skip = normalize(exclude) if exclude else None
|
||||
excluded = False
|
||||
# os.curdir is the literal ".", so restoring it left us inside `folder`.
|
||||
curdir = os.getcwd()
|
||||
os.chdir(folder)
|
||||
for root, _, files in os.walk('.'):
|
||||
# remove ./
|
||||
for f in files:
|
||||
md5_generator = md5()
|
||||
full_path = os.path.join(root, f)
|
||||
if skip and normalize(full_path) == skip:
|
||||
print(f"Excluding {full_path}...")
|
||||
excluded = True
|
||||
continue
|
||||
print(f"Processing {full_path}...")
|
||||
f = open(full_path, "rb")
|
||||
content = f.read()
|
||||
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
|
||||
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
||||
res[full_path] = (content_compressed, md5_code)
|
||||
os.chdir(curdir)
|
||||
if skip and not excluded:
|
||||
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
|
||||
return res
|
||||
|
||||
|
||||
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
|
||||
output_path = os.path.join(output_folder, "data.bin")
|
||||
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
|
||||
|
||||
|
||||
def write_blob(md5_table: dict, output_path: str, exe: str):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write("rustdesk".encode(encoding=encoding))
|
||||
for path in md5_table.keys():
|
||||
@@ -92,6 +111,14 @@ if __name__ == '__main__':
|
||||
help="the target used by cargo")
|
||||
parser.add_option("-l", "--level", dest="level", type="int",
|
||||
help="compression level, default is 11, highest", default=11)
|
||||
parser.add_option("--package", dest="package",
|
||||
help="write the per-customer blob to this path instead of "
|
||||
"data.bin, and skip the cargo build. Injected into the "
|
||||
"template's RDPKG resource so customizing needs no rebuild")
|
||||
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
|
||||
default=False,
|
||||
help="omit the executable from the blob, for a template whose "
|
||||
"executable ships in the package instead")
|
||||
(options, args) = parser.parse_args()
|
||||
folder = options.folder or './rustdesk'
|
||||
output_folder = os.path.abspath(options.output_folder or './')
|
||||
@@ -100,14 +127,29 @@ if __name__ == '__main__':
|
||||
options.executable = 'rustdesk.exe'
|
||||
if not options.executable.startswith(folder):
|
||||
options.executable = folder + '/' + options.executable
|
||||
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
|
||||
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
|
||||
# will result the print "Executable path: ..exe".
|
||||
# So we need to check if the executable is in the folder, and if so, concat again.
|
||||
if os.path.exists(os.path.join(folder, options.executable)):
|
||||
options.executable = os.path.join(folder, options.executable)
|
||||
folder_path = os.path.abspath(folder)
|
||||
exe: str = os.path.abspath(options.executable)
|
||||
if not exe.startswith(os.path.abspath(folder)):
|
||||
try:
|
||||
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
|
||||
except ValueError:
|
||||
in_source_folder = False
|
||||
if not in_source_folder:
|
||||
print("The executable must locate in source folder")
|
||||
exit(-1)
|
||||
exe = '.' + exe[len(os.path.abspath(folder)):]
|
||||
exe = '.' + exe[len(folder_path):]
|
||||
print("Executable path: " + exe)
|
||||
print("Compression level: " + str(options.level))
|
||||
md5_table = generate_md5_table(folder, options.level)
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
md5_table = generate_md5_table(
|
||||
folder, options.level, exe if options.exclude_exe else None)
|
||||
if options.package:
|
||||
write_blob(md5_table, os.path.abspath(options.package), exe)
|
||||
else:
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::{self},
|
||||
io::{Cursor, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
// The generic payload, shared by every customer and compiled in once per release.
|
||||
#[cfg(windows)]
|
||||
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
|
||||
#[cfg(not(windows))]
|
||||
const BIN_DATA: &[u8] = &[];
|
||||
|
||||
// The per-customer payload, injected into the RCDATA resource after the template
|
||||
// has been built, so that customizing a client needs no recompilation.
|
||||
#[cfg(windows)]
|
||||
const PACKAGE_RESOURCE_NAME: &str = "RDPKG";
|
||||
|
||||
// 4bytes
|
||||
const LENGTH: usize = 4;
|
||||
const IDENTIFIER: &[u8] = b"rustdesk";
|
||||
const IDENTIFIER_LENGTH: usize = 8;
|
||||
const MD5_LENGTH: usize = 32;
|
||||
const BUF_SIZE: usize = 4096;
|
||||
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
|
||||
pub(crate) struct BinaryReader {
|
||||
pub files: Vec<BinaryData>,
|
||||
pub exe: String,
|
||||
// Paths supplied by the per-customer package. Recorded so that a file dropped
|
||||
// from a later package -- a logo the customer removed, say -- can be deleted
|
||||
// from an existing extraction, which the timestamp wipe no longer covers now
|
||||
// that the packer is built once per release rather than once per customer.
|
||||
pub package_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BinaryReader {
|
||||
fn default() -> Self {
|
||||
let (files, exe) = BinaryReader::read();
|
||||
Self { files, exe }
|
||||
impl BinaryReader {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let package = read_package()?;
|
||||
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
let (files, exe) = merge(read_embedded()?, package);
|
||||
Ok(Self {
|
||||
files,
|
||||
exe,
|
||||
package_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Folds the per-customer package into the generic payload.
|
||||
fn merge(
|
||||
embedded: (Vec<BinaryData>, String),
|
||||
package: (Vec<BinaryData>, String),
|
||||
) -> (Vec<BinaryData>, String) {
|
||||
let (mut files, generic_exe) = embedded;
|
||||
let (package_files, package_exe) = package;
|
||||
|
||||
let exe = if package_exe.is_empty() {
|
||||
generic_exe.clone()
|
||||
} else {
|
||||
package_exe
|
||||
};
|
||||
|
||||
// The generic payload ships the executable under its stock name, the package
|
||||
// decides the final one. Rename on extraction so the process is always
|
||||
// `<appname>.exe`, which the app itself relies on to find its own sessions.
|
||||
if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) {
|
||||
let generic_key = normalize_path(&generic_exe);
|
||||
for file in files.iter_mut() {
|
||||
if normalize_path(&file.path) == generic_key {
|
||||
file.path = exe.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-customer entries replace the generic ones they shadow.
|
||||
if !package_files.is_empty() {
|
||||
let overridden: HashSet<String> = package_files
|
||||
.iter()
|
||||
.map(|file| normalize_path(&file.path))
|
||||
.collect();
|
||||
files.retain(|file| !overridden.contains(&normalize_path(&file.path)));
|
||||
files.extend(package_files);
|
||||
}
|
||||
|
||||
(files, exe)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_path(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
.trim_start_matches("./")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn read_u32(blob: &[u8], at: usize) -> Option<u32> {
|
||||
let bytes = blob.get(at..at + LENGTH)?;
|
||||
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
// Returns the files and the executable to launch, or None if the blob is absent or malformed.
|
||||
fn parse(blob: &'static [u8]) -> Option<(Vec<BinaryData>, String)> {
|
||||
let mut base = 0usize;
|
||||
let mut parsed = Vec::new();
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER {
|
||||
return None;
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
let path_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let path = std::str::from_utf8(blob.get(base..base + path_length)?)
|
||||
.ok()?
|
||||
.to_owned();
|
||||
base += path_length;
|
||||
let file_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let raw = blob.get(base..base + file_length)?;
|
||||
base += file_length;
|
||||
let md5_code = blob.get(base..base + MD5_LENGTH)?;
|
||||
base += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code,
|
||||
raw,
|
||||
path,
|
||||
});
|
||||
}
|
||||
let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned();
|
||||
Some((parsed, executable))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, String), String> {
|
||||
let Some(blob) = blob else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?;
|
||||
if package.1.trim().is_empty() {
|
||||
return Err("RDPKG resource has no executable".to_owned());
|
||||
}
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
// Reads an RCDATA resource out of the running image. Resources live in the mapped
|
||||
// image for the lifetime of the process, so the slice is genuinely 'static and no
|
||||
// copy is needed.
|
||||
#[cfg(windows)]
|
||||
fn read_resource(name: &str) -> Option<&'static [u8]> {
|
||||
use std::ptr::null_mut;
|
||||
use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource};
|
||||
|
||||
// MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA.
|
||||
const RT_RCDATA: *const u16 = 10 as _;
|
||||
|
||||
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA);
|
||||
if info.is_null() {
|
||||
return None;
|
||||
}
|
||||
let size = SizeofResource(null_mut(), info) as usize;
|
||||
if size == 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = LoadResource(null_mut(), info);
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = LockResource(handle) as *const u8;
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(std::slice::from_raw_parts(data, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,59 +235,6 @@ impl BinaryData {
|
||||
}
|
||||
|
||||
impl BinaryReader {
|
||||
fn read() -> (Vec<BinaryData>, String) {
|
||||
let mut base: usize = 0;
|
||||
let mut parsed = vec![];
|
||||
assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!");
|
||||
let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden != "rustdesk" {
|
||||
panic!("bin file is not valid!");
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden == "rustdesk" {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
// start reading
|
||||
let mut offset = 0;
|
||||
let path_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let path =
|
||||
String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length])
|
||||
.to_string();
|
||||
offset += path_length;
|
||||
// file sz
|
||||
let file_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let raw = &BIN_DATA[base + offset..base + offset + file_length];
|
||||
offset += file_length;
|
||||
// md5
|
||||
let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH];
|
||||
offset += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code: md5,
|
||||
raw: raw,
|
||||
path: path,
|
||||
});
|
||||
base += offset;
|
||||
}
|
||||
// executable
|
||||
let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string();
|
||||
(parsed, executable)
|
||||
}
|
||||
|
||||
#[cfg(linux)]
|
||||
pub fn configure_permission(&self, prefix: &Path) {
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
@@ -137,3 +251,155 @@ impl BinaryReader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Builds a blob in the same layout generate.py writes, so these tests pin the
|
||||
// cross-language format contract as well as the merge rules.
|
||||
fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
for (path, data) in files {
|
||||
out.extend_from_slice(&(path.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(path.as_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[b'a'; MD5_LENGTH]);
|
||||
}
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
out.extend_from_slice(exe.as_bytes());
|
||||
Box::leak(out.into_boxed_slice())
|
||||
}
|
||||
|
||||
fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| normalize_path(&file.path) == normalize_path(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_generate_py_layout() {
|
||||
let (files, exe) = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_blobs() {
|
||||
assert!(parse(b"".as_slice()).is_none());
|
||||
assert!(parse(b"notrustd".as_slice()).is_none());
|
||||
// Truncated mid-record rather than panicking on a slice out of range.
|
||||
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_an_absent_package_from_a_malformed_one() {
|
||||
assert!(parse_package_blob(None).unwrap().0.is_empty());
|
||||
assert!(parse_package_blob(Some(b"damaged")).is_err());
|
||||
assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_package_the_stock_payload_is_untouched() {
|
||||
let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap();
|
||||
let (files, exe) = merge(embedded, Default::default());
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert!(entry(&files, "./rustdesk.exe").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_the_stock_executable_to_the_package_name() {
|
||||
// x86: the big executable stays in the generic payload and only gets renamed.
|
||||
let embedded = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, "./rustdesk.exe").is_none());
|
||||
// Untouched neighbours survive.
|
||||
assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll");
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_entries_win_over_the_generic_payload() {
|
||||
// x64: the customized executable and icons ship in the package instead.
|
||||
let embedded = parse(blob(
|
||||
&[
|
||||
("./data/flutter_assets/assets/icon.ico", b"stock-icon"),
|
||||
("./librustdesk.dll", b"core"),
|
||||
],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(
|
||||
&[
|
||||
("./acme.exe", b"branded"),
|
||||
("./data/flutter_assets/assets/icon.ico", b"acme-icon"),
|
||||
],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert_eq!(
|
||||
entry(&files, "./data/flutter_assets/assets/icon.ico")
|
||||
.unwrap()
|
||||
.raw,
|
||||
b"acme-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
|
||||
let package = parse(blob(
|
||||
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
|
||||
|
||||
// Merging must not disturb them: the generic payload contributes none.
|
||||
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
|
||||
let (files, _) = merge(embedded, package);
|
||||
assert!(entry(&files, "./data/logo.png").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_paths_across_separator_styles() {
|
||||
// generate.py emits backslashes when it runs on Windows.
|
||||
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, ".\\rustdesk.exe").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use bin_reader::BinaryReader;
|
||||
use bin_reader::{normalize_path, BinaryReader};
|
||||
|
||||
pub mod bin_reader;
|
||||
#[cfg(windows)]
|
||||
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
|
||||
const APP_METADATA: &[u8] = &[];
|
||||
const APP_METADATA_CONFIG: &str = "meta.toml";
|
||||
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
|
||||
const META_LINE_PREFIX_FILE: &str = "file = ";
|
||||
const APP_PREFIX: &str = "rustdesk";
|
||||
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
|
||||
#[cfg(windows)]
|
||||
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
|
||||
|
||||
// The extraction directory follows whatever executable the payload asks for, so a
|
||||
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
|
||||
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
|
||||
fn app_dir_name(exe: &str) -> String {
|
||||
Path::new(&exe.replace('\\', "/"))
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.trim().to_lowercase())
|
||||
.filter(|stem| !stem.is_empty())
|
||||
.unwrap_or_else(|| APP_PREFIX.to_owned())
|
||||
}
|
||||
|
||||
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
|
||||
return true;
|
||||
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn write_meta(dir: &Path, ts: u64) {
|
||||
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
|
||||
let meta_file = dir.join(APP_METADATA_CONFIG);
|
||||
if ts != 0 {
|
||||
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
for path in package_paths {
|
||||
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
|
||||
}
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
}
|
||||
|
||||
fn previous_package_files(dir: &Path) -> Vec<String> {
|
||||
let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE))
|
||||
.map(|path| path.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// meta.toml is plain text in a user-writable directory, and it now drives deletion,
|
||||
// so the path is rebuilt from plain components rather than joined as written. A
|
||||
// prefix, root or parent component would otherwise escape the extraction directory:
|
||||
// Path::join replaces the base entirely when given an absolute path.
|
||||
fn resolve_within(dir: &Path, relative: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut path = dir.to_path_buf();
|
||||
let mut any = false;
|
||||
for component in Path::new(&relative.replace('\\', "/")).components() {
|
||||
match component {
|
||||
Component::Normal(part) => {
|
||||
// A drive-relative name like "C:x" parses as Normal, and only a
|
||||
// Windows host would classify "C:/..." as a Prefix, so the colon is
|
||||
// rejected outright rather than relying on the host's parser.
|
||||
if part.to_string_lossy().contains(':') {
|
||||
return None;
|
||||
}
|
||||
path.push(part);
|
||||
any = true;
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// A customer who drops a branding asset gets a package without it, and the file
|
||||
// would otherwise linger in an existing extraction and keep being used. The wipe
|
||||
// cannot cover this: it is keyed on the packer's build timestamp, which is now the
|
||||
// same for every customer of a release.
|
||||
fn remove_dropped_package_files_with<F>(
|
||||
dir: &Path,
|
||||
current: &[String],
|
||||
mut remove_file: F,
|
||||
) -> Vec<String>
|
||||
where
|
||||
F: FnMut(&Path) -> std::io::Result<()>,
|
||||
{
|
||||
let keep: std::collections::HashSet<String> =
|
||||
current.iter().map(|p| normalize_path(p)).collect();
|
||||
let mut failed = Vec::new();
|
||||
for previous in previous_package_files(dir) {
|
||||
if keep.contains(&normalize_path(&previous)) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = resolve_within(dir, &previous) else {
|
||||
continue;
|
||||
};
|
||||
if path.is_file() {
|
||||
println!("removing dropped {}", previous);
|
||||
if let Err(error) = remove_file(&path) {
|
||||
eprintln!("failed to remove dropped {}: {}", previous, error);
|
||||
failed.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec<String> {
|
||||
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
|
||||
}
|
||||
|
||||
fn setup(
|
||||
@@ -71,7 +164,7 @@ fn setup(
|
||||
} else {
|
||||
// home dir
|
||||
if let Some(dir) = dirs::data_local_dir() {
|
||||
dir.join(APP_PREFIX)
|
||||
dir.join(app_dir_name(&reader.exe))
|
||||
} else {
|
||||
eprintln!("not found data local dir");
|
||||
return None;
|
||||
@@ -87,10 +180,12 @@ fn setup(
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
let mut metadata_paths = reader.package_paths.clone();
|
||||
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
|
||||
for file in reader.files.iter() {
|
||||
file.write_to_file(&dir);
|
||||
}
|
||||
write_meta(&dir, ts);
|
||||
write_meta(&dir, ts, &metadata_paths);
|
||||
#[cfg(windows)]
|
||||
win::copy_runtime_broker(&dir);
|
||||
#[cfg(linux)]
|
||||
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
fn main() -> Result<(), String> {
|
||||
let mut args = Vec::new();
|
||||
let mut arg_exe = Default::default();
|
||||
let mut i = 0;
|
||||
@@ -193,7 +288,7 @@ fn main() {
|
||||
let quick_support = false;
|
||||
|
||||
let mut ui = false;
|
||||
let reader = BinaryReader::default();
|
||||
let reader = BinaryReader::new()?;
|
||||
if let Some(exe) = setup(
|
||||
reader,
|
||||
None,
|
||||
@@ -208,6 +303,7 @@ fn main() {
|
||||
}
|
||||
execute(exe, args, ui);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -246,3 +342,27 @@ mod win {
|
||||
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod meta_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_within_rejects_paths_that_escape() {
|
||||
let base = Path::new("/base");
|
||||
assert_eq!(
|
||||
resolve_within(base, "./data/logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_within(base, ".\\data\\logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
// meta.toml is user-writable, so these must not reach remove_file.
|
||||
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "/etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
|
||||
assert_eq!(resolve_within(base, "."), None);
|
||||
assert_eq!(resolve_within(base, ""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,15 @@ impl Display {
|
||||
.map(Display)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let displays_dxgi = Self::all_().unwrap_or(Default::default());
|
||||
let mut displays_dxgi = match Self::all_() {
|
||||
Ok(displays) => displays,
|
||||
Err(e) => {
|
||||
hbb_common::log::error!("DXGI display enumeration failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
|
||||
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
|
||||
|
||||
// Return gdi displays if dxgi is not supported
|
||||
if displays_dxgi.is_empty() {
|
||||
@@ -155,7 +163,6 @@ impl Display {
|
||||
}
|
||||
|
||||
// Reorder displays from dxgi
|
||||
let mut displays_dxgi = displays_dxgi;
|
||||
let mut displays_dxgi_ordered = Vec::new();
|
||||
for name in names_gdi.iter() {
|
||||
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
||||
@@ -176,11 +183,11 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.0.width() as usize
|
||||
self.0.width().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.0.height() as usize
|
||||
self.0.height().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
@@ -201,7 +208,8 @@ impl Display {
|
||||
|
||||
pub fn is_primary(&self) -> bool {
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
||||
self.origin() == (0, 0)
|
||||
// Detached outputs can still report origin (0,0) with a zero size.
|
||||
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
|
||||
@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
|
||||
// capturer rebuild loop clears about once a second.
|
||||
}
|
||||
|
||||
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
|
||||
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
|
||||
// every OTHER live capturer on a multi-display session. A capturer records this at build and
|
||||
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
|
||||
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
|
||||
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
|
||||
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_missing() -> bool {
|
||||
DISPLAYS.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "drm"))]
|
||||
pub fn bump_layout_generation() {
|
||||
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_generation() -> u64 {
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
// Otherwise, we use the logical size for `uinput`.
|
||||
if displays.len() == 1 {
|
||||
let d = &displays[0];
|
||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
||||
let (w, h) = oriented_physical(d);
|
||||
return Some((d.x, d.x + w, d.y, d.y + h));
|
||||
}
|
||||
|
||||
let mut min_x = i32::MAX;
|
||||
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
min_y = min_y.min(d.y);
|
||||
let size = if let Some(logical_size) = d.logical_size {
|
||||
logical_size
|
||||
} else if d.transform == 90 || d.transform == 270 {
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
@@ -374,6 +401,24 @@ pub struct DisplayRect {
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
|
||||
// otherwise identical; the remap itself matches by name and containment, never by this.
|
||||
pub transform: i32,
|
||||
}
|
||||
|
||||
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
|
||||
/// advertised and pointed at as HxW.
|
||||
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
|
||||
if d.transform == 90 || d.transform == 270 {
|
||||
(d.height, d.width)
|
||||
} else {
|
||||
(d.width, d.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical rectangles of a display list, for a caller that already has the list.
|
||||
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
logical_rects_of(displays)
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let (w, h) = if single {
|
||||
(d.width, d.height)
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
d.logical_size.unwrap_or((d.width, d.height))
|
||||
d.logical_size.unwrap_or_else(|| oriented_physical(d))
|
||||
};
|
||||
DisplayRect {
|
||||
name: d.name.clone(),
|
||||
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
y: d.y,
|
||||
w,
|
||||
h,
|
||||
transform: d.transform,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -495,8 +541,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clear_keeps_the_failure_stamp() {
|
||||
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
||||
// and dropping the stamp with it would defeat the backoff. Sole test touching these
|
||||
// statics; serialize before adding another.
|
||||
// and dropping the stamp with it would defeat the backoff. The generation test also
|
||||
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
|
||||
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
||||
clear_wayland_displays_cache();
|
||||
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
||||
@@ -519,6 +565,7 @@ mod tests {
|
||||
height,
|
||||
logical_size,
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +600,42 @@ mod tests {
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rotated_display_swaps_the_uinput_rect() {
|
||||
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
|
||||
// mode, so the pointer could not reach ~44% of a portrait screen.
|
||||
let mut d = display(0, 0, 1920, 1080, None);
|
||||
d.transform = 90;
|
||||
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
|
||||
let rects = logical_rects_of(&[d]);
|
||||
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_flip_is_visible_to_the_drift_comparison() {
|
||||
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
|
||||
// field is what lets `baseline != live` fire on them.
|
||||
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
|
||||
let mut b = a.clone();
|
||||
a.transform = 90;
|
||||
b.transform = 270;
|
||||
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_explicit_bump_moves_the_generation() {
|
||||
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
|
||||
// every other live capturer (adversarial finding on the first version of this).
|
||||
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
|
||||
clear_wayland_displays_cache();
|
||||
assert_eq!(
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
|
||||
before
|
||||
);
|
||||
bump_layout_generation();
|
||||
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
|
||||
}
|
||||
|
||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||
DisplayRect {
|
||||
name: name.to_owned(),
|
||||
@@ -560,6 +643,7 @@ mod tests {
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.9
|
||||
pkgver=1.5.0
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
417
res/admin-roles.py
Executable file
417
res/admin-roles.py
Executable file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
ROLE_TYPES = {
|
||||
"global": 1,
|
||||
"individual": 2,
|
||||
"group": 3,
|
||||
}
|
||||
|
||||
PERMISSION_IDS = {
|
||||
"users.view": 0x0101,
|
||||
"users.create": 0x0103,
|
||||
"users.invite": 0x0104,
|
||||
"users.delete": 0x0105,
|
||||
"users.enable_disable": 0x0106,
|
||||
"users.edit_email": 0x0107,
|
||||
"users.edit_password": 0x0108,
|
||||
"users.edit_note": 0x0109,
|
||||
"users.manage_2fa": 0x010A,
|
||||
"users.force_logout": 0x010B,
|
||||
"users.change_group": 0x010C,
|
||||
"users.change_strategy": 0x010D,
|
||||
"users.change_control_role": 0x010E,
|
||||
"users.edit_display_name": 0x010F,
|
||||
"devices.view": 0x0201,
|
||||
"devices.enable_disable": 0x0203,
|
||||
"devices.delete": 0x0204,
|
||||
"devices.edit_info": 0x0205,
|
||||
"devices.assign_to_user": 0x0206,
|
||||
"devices.change_group": 0x0207,
|
||||
"devices.change_strategy": 0x0208,
|
||||
"user_groups.view": 0x0301,
|
||||
"user_groups.edit": 0x0302,
|
||||
"device_groups.view": 0x0401,
|
||||
"device_groups.edit": 0x0402,
|
||||
"device_groups.change_strategy": 0x0403,
|
||||
"audits.view": 0x0501,
|
||||
"audits.edit": 0x0502,
|
||||
"strategies.view": 0x0601,
|
||||
"strategies.edit": 0x0602,
|
||||
"custom_clients.view": 0x0701,
|
||||
"custom_clients.edit": 0x0702,
|
||||
"control_roles.view": 0x0801,
|
||||
"control_roles.edit": 0x0802,
|
||||
}
|
||||
|
||||
PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def parse_permissions(value):
|
||||
permissions = []
|
||||
for item in split_csv(value) or []:
|
||||
permission = PERMISSION_IDS.get(item.lower())
|
||||
if permission is None:
|
||||
try:
|
||||
permission = int(item, 0)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid permission name or ID '{item}'")
|
||||
exit(1)
|
||||
if permission < 0 or permission > 65535:
|
||||
print(f"Error: Permission ID '{item}' is outside the 0-65535 range")
|
||||
exit(1)
|
||||
permissions.append(permission)
|
||||
return permissions
|
||||
|
||||
|
||||
def format_role_permissions(role):
|
||||
permissions = role.get("permissions")
|
||||
if isinstance(permissions, list):
|
||||
role["permissions"] = [
|
||||
PERMISSION_NAMES.get(permission, permission) for permission in permissions
|
||||
]
|
||||
return role
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, role_type=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if role_type is not None:
|
||||
params["type"] = ROLE_TYPES[role_type]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing admin roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
roles.extend(format_role_permissions(role) for role in rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
return format_role_permissions(role)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Admin role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(
|
||||
url,
|
||||
token,
|
||||
name,
|
||||
role_type,
|
||||
permissions,
|
||||
note=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {
|
||||
"name": name,
|
||||
"type": ROLE_TYPES[role_type],
|
||||
"permissions": permissions,
|
||||
}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if user_groups:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.post(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(
|
||||
url,
|
||||
token,
|
||||
guid,
|
||||
new_name=None,
|
||||
note=None,
|
||||
permissions=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if permissions is not None:
|
||||
payload["permissions"] = permissions
|
||||
if user_groups is not None:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups is not None:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.put(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/admin-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
method = requests.delete if remove else requests.post
|
||||
response = method(
|
||||
f"{url}/api/admin-roles/{guid}/users",
|
||||
headers=headers_with(token),
|
||||
json={"users": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"admin_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Admin role manager")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Admin role name")
|
||||
parser.add_argument("--guid", help="Admin role GUID")
|
||||
parser.add_argument("--new-name", help="New admin role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--type", choices=ROLE_TYPES, help="Role type")
|
||||
parser.add_argument(
|
||||
"--permissions",
|
||||
help="Comma-separated permission names or numeric IDs; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-groups",
|
||||
help="Comma-separated user group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device-groups",
|
||||
help="Comma-separated device group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
unassigned = parser.add_mutually_exclusive_group()
|
||||
unassigned.add_argument(
|
||||
"--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices"
|
||||
)
|
||||
unassigned.add_argument(
|
||||
"--no-unassigned",
|
||||
dest="unassigned",
|
||||
action="store_false",
|
||||
help="Exclude unassigned devices",
|
||||
)
|
||||
parser.set_defaults(unassigned=None)
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.type)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name or not args.type or args.permissions is None:
|
||||
parser.error("--name, --type, and --permissions are required for add")
|
||||
if args.type != "group" and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with --type group")
|
||||
create_role(
|
||||
args.url,
|
||||
args.token,
|
||||
args.name,
|
||||
args.type,
|
||||
parse_permissions(args.permissions),
|
||||
args.note,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Created admin role '{args.name}'")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
updates = [
|
||||
args.new_name,
|
||||
args.note,
|
||||
args.permissions,
|
||||
args.user_groups,
|
||||
args.device_groups,
|
||||
args.unassigned,
|
||||
]
|
||||
if all(value is None for value in updates):
|
||||
parser.error("at least one update option is required")
|
||||
if role.get("type") != ROLE_TYPES["group"] and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with a group role")
|
||||
update_role(
|
||||
args.url,
|
||||
args.token,
|
||||
role_guid,
|
||||
args.new_name,
|
||||
args.note,
|
||||
parse_permissions(args.permissions) if args.permissions is not None else None,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Updated admin role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted admin role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command in ("add-users", "remove-users"):
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for add-users and remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
remove = args.command == "remove-users"
|
||||
change_users(args.url, args.token, role_guid, user_guids, remove=remove)
|
||||
action = "Removed users from" if remove else "Added users to"
|
||||
print(f"Success: {action} admin role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
292
res/control-roles.py
Executable file
292
res/control-roles.py
Executable file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
STATUSES = {
|
||||
"disabled": 0,
|
||||
"enabled": 1,
|
||||
}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, status=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if status is not None:
|
||||
params["status"] = STATUSES[status]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing control roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
for role in rows:
|
||||
role.pop("info", None)
|
||||
roles.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
role.pop("info", None)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Control role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(url, token, name, note=None):
|
||||
payload = {"name": name}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.post(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(url, token, guid, new_name=None, note=None):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/control-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def set_status(url, token, guids, disable):
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/enable",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids, "disable": disable},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
if remove:
|
||||
endpoint = f"{url}/api/control-roles/users"
|
||||
response = requests.delete(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
else:
|
||||
endpoint = f"{url}/api/control-roles/{guid}/users"
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"control_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Control role manager (configure control permissions in the web console)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=[
|
||||
"view",
|
||||
"add",
|
||||
"update",
|
||||
"delete",
|
||||
"enable",
|
||||
"disable",
|
||||
"view-users",
|
||||
"assign-users",
|
||||
"remove-users",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Control role name")
|
||||
parser.add_argument("--guid", help="Control role GUID")
|
||||
parser.add_argument("--new-name", help="New control role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--status", choices=STATUSES, help="Status filter for view")
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.status)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name:
|
||||
parser.error("--name is required for add")
|
||||
create_role(args.url, args.token, args.name, args.note)
|
||||
print(f"Success: Created control role '{args.name}'")
|
||||
return
|
||||
|
||||
if args.command == "remove-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, None, user_guids, remove=True)
|
||||
print("Success: Removed users from their control roles")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
if args.new_name is None and args.note is None:
|
||||
parser.error("--new-name or --note is required for update")
|
||||
update_role(args.url, args.token, role_guid, args.new_name, args.note)
|
||||
print(f"Success: Updated control role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted control role '{role_name}'")
|
||||
elif args.command in ("enable", "disable"):
|
||||
disable = args.command == "disable"
|
||||
set_status(args.url, args.token, [role_guid], disable)
|
||||
print(f"Success: {args.command.title()}d control role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command == "assign-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for assign-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, role_guid, user_guids)
|
||||
print(f"Success: Assigned users to control role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
|
||||
|
||||
namespace RemotePrinter
|
||||
{
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder);
|
||||
VOID uninstallPrinter();
|
||||
// `appName` names the printer and its port. It is passed in rather than compiled
|
||||
// in so that a single dll serves every custom client; an empty value keeps the
|
||||
// stock "RustDesk Printer" name.
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
|
||||
VOID uninstallPrinter(const std::wstring& appName);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
|
||||
{
|
||||
do
|
||||
{
|
||||
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
|
||||
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
|
||||
{
|
||||
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
|
||||
if (process != NULL)
|
||||
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
int nResult = 0;
|
||||
LPWSTR installFolder = NULL;
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
std::wstring installFolderValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "InstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
// "<app name>|<install folder>". Split here rather than through
|
||||
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
|
||||
// Formatted property value cannot carry.
|
||||
{
|
||||
std::wstring data(pwzData);
|
||||
size_t separator = data.find(L'|');
|
||||
if (separator == std::wstring::npos)
|
||||
{
|
||||
// A package built before the name was passed in; keep the stock name.
|
||||
appNameValue.clear();
|
||||
installFolderValue = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
appNameValue = data.substr(0, separator);
|
||||
installFolderValue = data.substr(separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
|
||||
RemotePrinter::installUpdatePrinter(installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
|
||||
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
||||
|
||||
LExit:
|
||||
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
|
||||
HRESULT hr = S_OK;
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
// Must match the name install used, otherwise the printer is left behind. Absent
|
||||
// on packages built before this was passed in, where it was the stock name.
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
if (pwzData)
|
||||
{
|
||||
appNameValue = pwzData;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
|
||||
RemotePrinter::uninstallPrinter();
|
||||
RemotePrinter::uninstallPrinter(appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
||||
|
||||
LExit:
|
||||
if (pwzData) {
|
||||
ReleaseStr(pwzData);
|
||||
}
|
||||
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,19 @@ namespace RemotePrinter
|
||||
{
|
||||
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
|
||||
|
||||
// The driver files and the driver name ship with the app under their stock names
|
||||
// and stay fixed for every custom client. Only the printer and its port carry the
|
||||
// app name, and that arrives at runtime so one dll serves every custom client.
|
||||
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
|
||||
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
|
||||
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
|
||||
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
|
||||
|
||||
static std::wstring printerNameOf(const std::wstring &appName)
|
||||
{
|
||||
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
|
||||
}
|
||||
|
||||
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
|
||||
template <typename T, typename R>
|
||||
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
|
||||
@@ -458,8 +465,12 @@ namespace RemotePrinter
|
||||
// We should not check the driver version because the driver is deployed with the application.
|
||||
// It's better to uninstall the existing driver and install the driver from the application.
|
||||
// 3. Add the printer.
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder)
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
|
||||
{
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
|
||||
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
|
||||
|
||||
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
|
||||
if (!FileExists(infFile))
|
||||
{
|
||||
@@ -505,13 +516,15 @@ namespace RemotePrinter
|
||||
}
|
||||
}
|
||||
|
||||
VOID uninstallPrinter()
|
||||
VOID uninstallPrinter(const std::wstring &appName)
|
||||
{
|
||||
deletePrinter(RD_PRINTER_NAME);
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
|
||||
deletePrinter(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
|
||||
uninstallDriver(RD_PRINTER_DRIVER_NAME);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
|
||||
checkDeleteLocalPort(RD_PRINTER_PORT);
|
||||
checkDeleteLocalPort(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
|
||||
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
|
||||
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
|
||||
<!-- The app name comes first and is separated by '|', which cannot occur in a
|
||||
Windows path nor in a validated app name. wcautil's own delimiter is a
|
||||
literal wide char 128 that a Formatted value cannot carry, and [~] is
|
||||
MSI's NUL escape rather than that delimiter, so the action parses this
|
||||
itself. Passing the name keeps the dll free of it, so one build serves
|
||||
every custom client. -->
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
|
||||
<InstallExecuteSequence>
|
||||
|
||||
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
||||
@@ -86,6 +93,7 @@
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<PropertyRef Id="AddRemovePropertiesFile" />
|
||||
|
||||
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
||||
<!--$Media2Start$-->
|
||||
<!-- preprocess.py in template mode adds a second cabinet here, holding only
|
||||
the files that differ per customer, so a custom client can be produced by
|
||||
rebuilding that small cabinet instead of the whole package. The shipped
|
||||
msi is built without template mode and keeps a single cabinet. -->
|
||||
<!--$Media2End$-->
|
||||
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
||||
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
import re
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
import shutil
|
||||
from xml.sax.saxutils import quoteattr
|
||||
|
||||
@@ -67,6 +66,14 @@ def make_parser():
|
||||
parser.add_argument(
|
||||
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build a template to be patched per customer rather than a finished "
|
||||
"package: puts the files a custom client replaces in their own cabinet, so "
|
||||
"rebranding rebuilds a few hundred KB instead of the whole payload.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conn-type",
|
||||
type=str,
|
||||
@@ -92,6 +99,43 @@ def make_parser():
|
||||
return parser
|
||||
|
||||
|
||||
# Files a custom client replaces. Kept in their own cabinet by --template so that
|
||||
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
|
||||
# The app executable is handled separately: it has its own component in RustDesk.wxs.
|
||||
#
|
||||
# A template has to ship a placeholder for each of these so there is a File row to
|
||||
# patch, but the branding assets are optional for a customer and a stock build has
|
||||
# none of them at all. So each optional one installs only when its property is set,
|
||||
# which the patcher does for the files a customer actually supplied. Otherwise a
|
||||
# customer without a logo would install the placeholder, where today they get no
|
||||
# logo at all -- the client treats a missing asset as "no logo".
|
||||
PER_CUSTOMER_DISK_ID = 2
|
||||
PER_CUSTOMER_FILES = {
|
||||
# relative path -> property gating installation, or None if always installed
|
||||
"custom.txt": None,
|
||||
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
|
||||
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
|
||||
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
|
||||
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
|
||||
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
|
||||
}
|
||||
|
||||
|
||||
def normalize_relative(relative_path):
|
||||
path = relative_path.replace("\\", "/")
|
||||
while path.startswith("./"):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def is_per_customer(relative_path):
|
||||
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
|
||||
|
||||
|
||||
def per_customer_condition(relative_path):
|
||||
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
|
||||
|
||||
|
||||
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
return lines, index_start
|
||||
|
||||
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
|
||||
indent = g_indent_unit * 3
|
||||
path = Path(dist_dir)
|
||||
idx = 1
|
||||
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
if subdir != ".":
|
||||
dir_attr = f'Subdirectory="{subdir}"'
|
||||
|
||||
relative = file_path.relative_to(path).as_posix()
|
||||
disk_attr = ""
|
||||
condition_attr = ""
|
||||
if template and is_per_customer(relative):
|
||||
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
|
||||
# Branding assets are optional, and the template only carries a
|
||||
# placeholder, so install one only when the customer supplied it.
|
||||
condition = per_customer_condition(relative)
|
||||
if condition:
|
||||
condition_attr = f' Condition="{condition} = 1"'
|
||||
|
||||
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
||||
# because it will cause error
|
||||
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
||||
to_insert_lines = f"""
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
||||
{indent}</Component>
|
||||
"""
|
||||
lines.insert(index_start + 1, to_insert_lines[1:])
|
||||
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
return True
|
||||
|
||||
|
||||
def gen_auto_component(app_name, dist_dir):
|
||||
def gen_auto_component(app_name, dist_dir, template=False):
|
||||
return gen_content_between_tags(
|
||||
"Package/Components/RustDesk.wxs",
|
||||
"<!--$AutoComonentStart$-->",
|
||||
"<!--$AutoComponentEnd$-->",
|
||||
lambda lines, index_start: insert_components_between_tags(
|
||||
lines, index_start, app_name, dist_dir
|
||||
lines, index_start, app_name, dist_dir, template
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def gen_media2():
|
||||
"""Second cabinet holding only what a custom client replaces."""
|
||||
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 2
|
||||
lines.insert(
|
||||
index_start + 1,
|
||||
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
|
||||
' EmbedCab="yes" CompressionLevel="high" />\n',
|
||||
)
|
||||
return lines
|
||||
|
||||
return gen_content_between_tags(
|
||||
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
|
||||
)
|
||||
|
||||
|
||||
def put_app_exe_on_media2():
|
||||
"""The app executable has its own component, so it is moved by name."""
|
||||
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
|
||||
with open(target, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
|
||||
new = (
|
||||
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
|
||||
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
|
||||
)
|
||||
if content.count(old) != 1:
|
||||
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
|
||||
return False
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
f.write(content.replace(old, new))
|
||||
return True
|
||||
|
||||
|
||||
def gen_pre_vars(args, dist_dir):
|
||||
def func(lines, index_start):
|
||||
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
||||
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def replace_app_name_in_custom_actions(app_name):
|
||||
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
|
||||
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
line = re.sub(r"\bRustDesk\b", app_name, line)
|
||||
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
|
||||
lines[i] = line
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def gen_upgrade_info():
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 3
|
||||
@@ -478,11 +556,16 @@ if __name__ == "__main__":
|
||||
if not gen_conn_type(args):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir):
|
||||
if args.template:
|
||||
if not gen_media2():
|
||||
sys.exit(-1)
|
||||
if not put_app_exe_on_media2():
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir, args.template):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_custom_dialog_bitmaps():
|
||||
sys.exit(-1)
|
||||
|
||||
replace_app_name_in_langs(args.app_name)
|
||||
replace_app_name_in_custom_actions(args.app_name)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -87,7 +87,7 @@ if(VCPKG_HOST_IS_WINDOWS)
|
||||
vcpkg_acquire_msys(MSYS_ROOT PACKAGES automake1.16)
|
||||
set(SHELL "${MSYS_ROOT}/usr/bin/bash.exe")
|
||||
vcpkg_add_to_path("${MSYS_ROOT}/usr/share/automake-1.16")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX}")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX} ")
|
||||
else()
|
||||
find_program(SHELL bash)
|
||||
endif()
|
||||
|
||||
@@ -1753,6 +1753,18 @@ pub struct LoginConfigHandler {
|
||||
pub remember: bool,
|
||||
config: PeerConfig,
|
||||
pub port_forward: (String, i32),
|
||||
/// This login's `multiplex`, filled with `port_forward` under the turn
|
||||
/// lock. `port_forward_mux` says whether a mapping probes for the tunnel;
|
||||
/// one the probe latched to the raw pipe logs in without asking, so an
|
||||
/// upgraded peer keeps giving it the raw pipe.
|
||||
pub(crate) port_forward_multiplex: bool,
|
||||
/// Set once per window, before its mappings start: every accept's claim
|
||||
/// reads it.
|
||||
pub(crate) port_forward_mux: bool,
|
||||
/// Held by a port-forward mapping from filling `port_forward` and `hash`
|
||||
/// until its login is built from them; a window's mappings log in
|
||||
/// concurrently.
|
||||
pub(crate) port_forward_login_turn: Arc<hbb_common::tokio::sync::Mutex<()>>,
|
||||
pub version: i64,
|
||||
features: Option<Features>,
|
||||
pub session_id: u64, // used for local <-> server communication
|
||||
@@ -1792,6 +1804,10 @@ impl Deref for LoginConfigHandler {
|
||||
}
|
||||
|
||||
impl LoginConfigHandler {
|
||||
pub(crate) fn set_hash(&mut self, hash: Hash) {
|
||||
self.hash = hash;
|
||||
}
|
||||
|
||||
/// Initialize the login config handler.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -2761,6 +2777,7 @@ impl LoginConfigHandler {
|
||||
ConnType::PORT_FORWARD | ConnType::RDP => lr.set_port_forward(PortForward {
|
||||
host: self.port_forward.0.clone(),
|
||||
port: self.port_forward.1,
|
||||
multiplex: self.port_forward_multiplex,
|
||||
..Default::default()
|
||||
}),
|
||||
ConnType::TERMINAL => {
|
||||
@@ -4058,6 +4075,26 @@ mod retry_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod port_forward_mux_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_login_asks_for_the_tunnel_when_its_mapping_probes() {
|
||||
let mut lc = LoginConfigHandler::default();
|
||||
lc.conn_type = ConnType::PORT_FORWARD;
|
||||
let asks = |lc: &LoginConfigHandler| {
|
||||
lc.create_login_msg(String::new(), String::new(), vec![])
|
||||
.login_request()
|
||||
.port_forward()
|
||||
.multiplex
|
||||
};
|
||||
assert!(!asks(&lc));
|
||||
lc.port_forward_multiplex = true;
|
||||
assert!(asks(&lc));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hc_connection(
|
||||
feedback: i32,
|
||||
rendezvous_server: String,
|
||||
|
||||
@@ -1462,6 +1462,18 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
))]
|
||||
if self.handler.is_text_clipboard_required()
|
||||
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
|
||||
{
|
||||
let mut msg = Message::new();
|
||||
msg.set_clipboard(cb.clone());
|
||||
let session_id = self.handler.lc.read().unwrap().session_id;
|
||||
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
update_clipboard(vec![cb], ClipboardSide::Client);
|
||||
#[cfg(target_os = "ios")]
|
||||
@@ -1485,6 +1497,18 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
))]
|
||||
if self.handler.is_text_clipboard_required()
|
||||
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
|
||||
{
|
||||
let mut msg = Message::new();
|
||||
msg.set_multi_clipboards(_mcb.clone());
|
||||
let session_id = self.handler.lc.read().unwrap().session_id;
|
||||
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
update_clipboard(_mcb.clipboards, ClipboardSide::Client);
|
||||
#[cfg(target_os = "ios")]
|
||||
|
||||
@@ -13,6 +13,17 @@ pub const CLIPBOARD_NAME: &'static str = "clipboard";
|
||||
pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard";
|
||||
pub const CLIPBOARD_INTERVAL: u64 = 333;
|
||||
|
||||
pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
|
||||
#[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))]
|
||||
pub fn is_sync_clipboard_between_sessions_enabled() -> bool {
|
||||
hbb_common::config::option2bool(
|
||||
OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS,
|
||||
&hbb_common::config::LocalConfig::get_option(OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS),
|
||||
)
|
||||
}
|
||||
|
||||
// This format is used to store the flag in the clipboard.
|
||||
const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner";
|
||||
|
||||
|
||||
@@ -1422,10 +1422,26 @@ pub fn update_file_clipboard_required() {
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
|
||||
send_clipboard_msg_impl(msg, _is_file, None);
|
||||
}
|
||||
|
||||
// `except_session_id` is the session the content came from, to avoid sending it back.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn send_clipboard_msg_to_other_sessions(msg: Message, except_session_id: u64) {
|
||||
send_clipboard_msg_impl(msg, false, Some(except_session_id));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Option<u64>) {
|
||||
for s in sessions::get_sessions() {
|
||||
if !s.is_default() {
|
||||
continue;
|
||||
}
|
||||
if let Some(except_session_id) = except_session_id {
|
||||
if s.lc.read().unwrap().session_id == except_session_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
if _is_file {
|
||||
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"),
|
||||
("screenshot-action-tip", "إجراء لقطة الشاشة"),
|
||||
("Save as", "حفظ باسم"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "تصدير"),
|
||||
("Export Logs", "تصدير السجلات"),
|
||||
("Import Folder", "استيراد مجلد"),
|
||||
("Copy to clipboard", "نسخ إلى الحافظة"),
|
||||
("Enable remote printer", "تمكين الطابعة عن بُعد"),
|
||||
("Downloading {}", "جارٍ تنزيل {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "متابعة"),
|
||||
("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."),
|
||||
("Lock canvas", "قفل اللوحة"),
|
||||
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
|
||||
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "تفعيل"),
|
||||
("Reuse one connection for port forwarding", "إعادة استخدام اتصال واحد لإعادة توجيه المنافذ"),
|
||||
("port-forward-mux-tip", "تمرير جميع اتصالات إعادة توجيه المنافذ عبر اتصال واحد بالجهاز الآخر، بدلاً من الاتصال وتسجيل الدخول من جديد لكل اتصال."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."),
|
||||
("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."),
|
||||
("Save as", "Захаваць у файл"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Экспартаваць"),
|
||||
("Export Logs", "Экспартаваць журналы"),
|
||||
("Import Folder", "Імпартаваць папку"),
|
||||
("Copy to clipboard", "Скапіяваць у буфер абмену"),
|
||||
("Enable remote printer", "Выкарыстоўваць аддалены прынтар"),
|
||||
("Downloading {}", "Ідзе спампоўванне {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Працягнуць"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."),
|
||||
("Lock canvas", "Заблакіраваць палатно"),
|
||||
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
|
||||
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Уключыць"),
|
||||
("Reuse one connection for port forwarding", "Выкарыстоўваць адно злучэнне для перанакіравання партоў"),
|
||||
("port-forward-mux-tip", "Перадаваць усе злучэнні аднаго перанакіравання партоў праз адно злучэнне з аддаленай прыладай замест паўторнага падлучэння і ўваходу для кожнага з іх."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."),
|
||||
("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."),
|
||||
("Save as", "Запазване като"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Изнасяне"),
|
||||
("Export Logs", "Изнасяне на дневниците"),
|
||||
("Import Folder", "Внасяне на папка"),
|
||||
("Copy to clipboard", "Копиране в клипборда"),
|
||||
("Enable remote printer", "Позволяване на отдалечен принтер"),
|
||||
("Downloading {}", "Изтегляне на {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Продължи"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."),
|
||||
("Lock canvas", "Заключване на платното"),
|
||||
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Активирай"),
|
||||
("Reuse one connection for port forwarding", "Използване на една връзка за пренасочване на портове"),
|
||||
("port-forward-mux-tip", "Всички връзки на едно пренасочване на портове минават през една връзка към отсрещния компютър, вместо да се свързвате и влизате отново за всяка от тях."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."),
|
||||
("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."),
|
||||
("Save as", "Anomena i desa"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exporta"),
|
||||
("Export Logs", "Exporta els registres"),
|
||||
("Import Folder", "Importa una carpeta"),
|
||||
("Copy to clipboard", "Copia al porta-retalls"),
|
||||
("Enable remote printer", "Habilita l'impressora remota"),
|
||||
("Downloading {}", "Descarregant {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continua"),
|
||||
("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."),
|
||||
("Lock canvas", "Bloca el llenç"),
|
||||
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilita"),
|
||||
("Reuse one connection for port forwarding", "Reutilitza una connexió per a la redirecció de ports"),
|
||||
("port-forward-mux-tip", "Fa passar totes les connexions d'una redirecció de ports per una única connexió amb l'altre equip, en lloc de connectar i iniciar la sessió de nou per a cadascuna."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
|
||||
("screenshot-action-tip", "请选择如何继续截屏。"),
|
||||
("Save as", "另存为"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "导出"),
|
||||
("Export Logs", "导出日志"),
|
||||
("Import Folder", "导入文件夹"),
|
||||
("Copy to clipboard", "复制到剪贴板"),
|
||||
("Enable remote printer", "启用远程打印机"),
|
||||
("Downloading {}", "正在下载 {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "继续"),
|
||||
("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"),
|
||||
("Lock canvas", "锁定画布"),
|
||||
("Sync clipboard between sessions", "在会话间同步剪贴板"),
|
||||
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", "允许终端应用复制到剪贴板"),
|
||||
("Enable", "启用"),
|
||||
("Reuse one connection for port forwarding", "端口转发复用同一条连接"),
|
||||
("port-forward-mux-tip", "同一条端口转发规则上的所有连接共用一条到对方的连接,而不是每条连接都重新连接并登录一次。"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."),
|
||||
("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."),
|
||||
("Save as", "Uložit jako"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportovat"),
|
||||
("Export Logs", "Exportovat protokoly"),
|
||||
("Import Folder", "Importovat složku"),
|
||||
("Copy to clipboard", "Kopírovat do schránky"),
|
||||
("Enable remote printer", "Povolit vzdálenou tiskárnu"),
|
||||
("Downloading {}", "Stahuje se {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Pokračovat"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."),
|
||||
("Lock canvas", "Zamknout zobrazení"),
|
||||
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
|
||||
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Povolit"),
|
||||
("Reuse one connection for port forwarding", "Znovu použít jedno připojení pro přesměrování portů"),
|
||||
("port-forward-mux-tip", "Vede všechna připojení jednoho přesměrování portů přes jediné připojení k protějšku místo opakovaného připojování a přihlašování pro každé z nich."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."),
|
||||
("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."),
|
||||
("Save as", "Gem som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksportér"),
|
||||
("Export Logs", "Eksportér logfiler"),
|
||||
("Import Folder", "Importér mappe"),
|
||||
("Copy to clipboard", "Kopiér til udklipsholder"),
|
||||
("Enable remote printer", "Aktivér fjernprinter"),
|
||||
("Downloading {}", "Downloader {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Fortsæt"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."),
|
||||
("Lock canvas", "Lås lærred"),
|
||||
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivér"),
|
||||
("Reuse one connection for port forwarding", "Genbrug én forbindelse til portvideresendelse"),
|
||||
("port-forward-mux-tip", "Fører alle forbindelser i en portvideresendelse gennem én enkelt forbindelse til modparten i stedet for at forbinde og logge ind igen for hver enkelt."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."),
|
||||
("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."),
|
||||
("Save as", "Speichern unter"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportieren"),
|
||||
("Export Logs", "Protokolle exportieren"),
|
||||
("Import Folder", "Ordner importieren"),
|
||||
("Copy to clipboard", "In Zwischenablage kopieren"),
|
||||
("Enable remote printer", "Entfernten Drucker aktivieren"),
|
||||
("Downloading {}", "{} herunterladen"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Weiter"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."),
|
||||
("Lock canvas", "Sichtfeld sperren"),
|
||||
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
|
||||
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivieren"),
|
||||
("Reuse one connection for port forwarding", "Eine Verbindung für die Portweiterleitung wiederverwenden"),
|
||||
("port-forward-mux-tip", "Alle Verbindungen einer Portweiterleitung über eine einzige Verbindung zur Gegenstelle führen, statt sich für jede einzelne neu zu verbinden und anzumelden."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."),
|
||||
("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."),
|
||||
("Save as", "Αποθήκευση ως"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Εξαγωγή"),
|
||||
("Export Logs", "Εξαγωγή αρχείων καταγραφής"),
|
||||
("Import Folder", "Εισαγωγή φακέλου"),
|
||||
("Copy to clipboard", "Αντιγραφή στο πρόχειρο"),
|
||||
("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"),
|
||||
("Downloading {}", "Γίνεται Λήψη {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Συνέχεια"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."),
|
||||
("Lock canvas", "Κλείδωμα καμβά"),
|
||||
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
|
||||
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ενεργοποίηση"),
|
||||
("Reuse one connection for port forwarding", "Επαναχρησιμοποίηση μίας σύνδεσης για την προώθηση θυρών"),
|
||||
("port-forward-mux-tip", "Όλες οι συνδέσεις μιας προώθησης θυρών περνούν από μία μόνο σύνδεση προς τον απομακρυσμένο υπολογιστή, αντί να πραγματοποιείται νέα σύνδεση και ταυτοποίηση για κάθε μία."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -275,5 +275,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("id_whitelist_caveat_tip", "The ID is reported by the connecting client. This whitelist reduces exposure and does not replace the password or 2FA."),
|
||||
("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"),
|
||||
("Your ip is blocked by the peer", "Your IP is blocked by the peer"),
|
||||
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
|
||||
("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."),
|
||||
("port-forward-mux-tip", "Carry every connection of a port-forward mapping over a single connection to the peer, instead of connecting and logging in again for each one."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."),
|
||||
("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."),
|
||||
("Save as", "Konservi kiel"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksporti"),
|
||||
("Export Logs", "Eksporti protokolojn"),
|
||||
("Import Folder", "Importi dosierujon"),
|
||||
("Copy to clipboard", "Kopii al la poŝo"),
|
||||
("Enable remote printer", "Ebligi foran presilon"),
|
||||
("Downloading {}", "Elŝutas {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Daŭrigi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."),
|
||||
("Lock canvas", "Ŝlosi kanvason"),
|
||||
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ebligi"),
|
||||
("Reuse one connection for port forwarding", "Reuzi unu konekton por pordo-plusendado"),
|
||||
("port-forward-mux-tip", "Ĉiuj konektoj de unu pordo-plusendado iras tra unu sola konekto al la alia komputilo, anstataŭ konekti kaj ensaluti denove por ĉiu el ili."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."),
|
||||
("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."),
|
||||
("Save as", "Guardar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportar"),
|
||||
("Export Logs", "Exportar registros"),
|
||||
("Import Folder", "Importar carpeta"),
|
||||
("Copy to clipboard", "Copiar al portapapeles"),
|
||||
("Enable remote printer", "Habilitar impresora remota"),
|
||||
("Downloading {}", "Descargando {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuar"),
|
||||
("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."),
|
||||
("Lock canvas", "Bloquear lienzo"),
|
||||
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
|
||||
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilitar"),
|
||||
("Reuse one connection for port forwarding", "Reutilizar una conexión para la redirección de puertos"),
|
||||
("port-forward-mux-tip", "Llevar todas las conexiones de una redirección de puertos por una única conexión con el otro equipo, en lugar de conectar e iniciar sesión de nuevo para cada una."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."),
|
||||
("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."),
|
||||
("Save as", "Salvesta kui"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Ekspordi"),
|
||||
("Export Logs", "Ekspordi logid"),
|
||||
("Import Folder", "Impordi kaust"),
|
||||
("Copy to clipboard", "Kopeeri lõikelauale"),
|
||||
("Enable remote printer", "Luba kaugprinter"),
|
||||
("Downloading {}", "Allalaadimine: {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jätka"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."),
|
||||
("Lock canvas", "Lukusta lõuend"),
|
||||
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
|
||||
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Luba"),
|
||||
("Reuse one connection for port forwarding", "Kasuta pordi suunamiseks üht ühendust"),
|
||||
("port-forward-mux-tip", "Juhib ühe pordisuunamise kõik ühendused ühe teise arvutiga loodud ühenduse kaudu, selle asemel et iga ühenduse jaoks uuesti ühenduda ja sisse logida."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."),
|
||||
("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."),
|
||||
("Save as", "Gorde honela"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Esportatu"),
|
||||
("Export Logs", "Esportatu erregistroak"),
|
||||
("Import Folder", "Inportatu karpeta"),
|
||||
("Copy to clipboard", "Kopiatu arbelera"),
|
||||
("Enable remote printer", "Gaitu urruneko inprimagailua"),
|
||||
("Downloading {}", "{} deskargatzen"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jarraitu"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."),
|
||||
("Lock canvas", "Blokeatu oihala"),
|
||||
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
|
||||
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Gaitu"),
|
||||
("Reuse one connection for port forwarding", "Berrerabili konexio bakarra portuen birbideratzerako"),
|
||||
("port-forward-mux-tip", "Portu-birbideratze baten konexio guztiak beste ordenagailurako konexio bakar batetik eramaten ditu, bakoitzerako berriro konektatu eta saioa hasi beharrean."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."),
|
||||
("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."),
|
||||
("Save as", "ذخیره به عنوان"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "خروجی گرفتن"),
|
||||
("Export Logs", "خروجی گرفتن از گزارشها"),
|
||||
("Import Folder", "درونریزی پوشه"),
|
||||
("Copy to clipboard", "در کلیپ بورد کپی کنید"),
|
||||
("Enable remote printer", "چاپگر از راه دور را فعال کنید"),
|
||||
("Downloading {}", "بارگیری {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "ادامه"),
|
||||
("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."),
|
||||
("Lock canvas", "قفل کردن صفحه"),
|
||||
("Sync clipboard between sessions", "همگامسازی کلیپبورد بین نشستها"),
|
||||
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی میشوند به کلیپبورد سایر نشستهای متصل شما نیز ارسال میشوند."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "فعالسازی"),
|
||||
("Reuse one connection for port forwarding", "استفاده مجدد از یک اتصال برای هدایت پورت"),
|
||||
("port-forward-mux-tip", "همه اتصالهای یک هدایت پورت از یک اتصال واحد به دستگاه مقابل عبور میکنند، بهجای اتصال و ورود دوباره برای هر کدام."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"),
|
||||
("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"),
|
||||
("Save as", "Tallenna nimellä"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Vie"),
|
||||
("Export Logs", "Vie lokit"),
|
||||
("Import Folder", "Tuo kansio"),
|
||||
("Copy to clipboard", "Kopioi leikepöydälle"),
|
||||
("Enable remote printer", "Ota etätulostin käyttöön"),
|
||||
("Downloading {}", "Ladataan {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jatka"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."),
|
||||
("Lock canvas", "Lukitse näkymä"),
|
||||
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
|
||||
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ota käyttöön"),
|
||||
("Reuse one connection for port forwarding", "Käytä yhtä yhteyttä portin edelleenohjaukseen"),
|
||||
("port-forward-mux-tip", "Välittää kaikki yhden portin edelleenohjauksen yhteydet yhden vastapuoleen avatun yhteyden kautta sen sijaan, että jokaista varten muodostettaisiin yhteys ja kirjauduttaisiin uudelleen."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."),
|
||||
("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."),
|
||||
("Save as", "Enregistrer sous"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exporter"),
|
||||
("Export Logs", "Exporter les journaux"),
|
||||
("Import Folder", "Importer un dossier"),
|
||||
("Copy to clipboard", "Copier dans le presse-papier"),
|
||||
("Enable remote printer", "Activer l’impression à distance"),
|
||||
("Downloading {}", "Téléchargement de {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuer"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Le navigateur ne s’est pas ouvert ? Utilisez l’URL ci-dessous pour vous connecter."),
|
||||
("Lock canvas", "Verrouiller la vue"),
|
||||
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Activer"),
|
||||
("Reuse one connection for port forwarding", "Réutiliser une seule connexion pour la redirection de ports"),
|
||||
("port-forward-mux-tip", "Faire passer toutes les connexions d'une redirection de ports par une seule connexion vers le pair, au lieu de se connecter et de s'authentifier à nouveau pour chacune."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."),
|
||||
("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."),
|
||||
("Save as", "შენახვა როგორც"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "ექსპორტი"),
|
||||
("Export Logs", "ჟურნალების ექსპორტი"),
|
||||
("Import Folder", "საქაღალდის იმპორტი"),
|
||||
("Copy to clipboard", "ბუფერში კოპირება"),
|
||||
("Enable remote printer", "დისტანციური პრინტერის ჩართვა"),
|
||||
("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "გაგრძელება"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."),
|
||||
("Lock canvas", "ტილოს დაბლოკვა"),
|
||||
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
|
||||
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "ჩართვა"),
|
||||
("Reuse one connection for port forwarding", "პორტის გადამისამართებისთვის ერთი კავშირის ხელახლა გამოყენება"),
|
||||
("port-forward-mux-tip", "ერთი პორტის გადამისამართების ყველა კავშირი გადის მეორე კომპიუტერთან დამყარებული ერთი კავშირით, ნაცვლად იმისა, რომ თითოეულისთვის თავიდან დაუკავშირდეს და შევიდეს სისტემაში."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."),
|
||||
("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"),
|
||||
("Save as", "તરીકે સાચવો"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "એક્સપોર્ટ કરો"),
|
||||
("Export Logs", "લોગ એક્સપોર્ટ કરો"),
|
||||
("Import Folder", "ફોલ્ડર ઇમ્પોર્ટ કરો"),
|
||||
("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"),
|
||||
("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"),
|
||||
("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "ચાલુ રાખો"),
|
||||
("Browser didn't open? Use the url below to sign in.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."),
|
||||
("Lock canvas", "કેનવાસ લોક કરો"),
|
||||
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
|
||||
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "સક્ષમ કરો"),
|
||||
("Reuse one connection for port forwarding", "પોર્ટ ફોરવર્ડિંગ માટે એક જ કનેક્શન ફરી વાપરો"),
|
||||
("port-forward-mux-tip", "એક પોર્ટ ફોરવર્ડિંગનાં બધાં કનેક્શન સામેના કમ્પ્યુટર સાથેના એક જ કનેક્શન મારફતે જાય છે, દરેક માટે ફરીથી કનેક્ટ અને લોગિન કરવાને બદલે."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"),
|
||||
("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"),
|
||||
("Save as", "שמור בשם"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "ייצוא"),
|
||||
("Export Logs", "ייצוא יומנים"),
|
||||
("Import Folder", "ייבוא תיקייה"),
|
||||
("Copy to clipboard", "העתק ללוח"),
|
||||
("Enable remote printer", "אפשר מדפסת מרוחקת"),
|
||||
("Downloading {}", "מוריד את {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "המשך"),
|
||||
("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."),
|
||||
("Lock canvas", "נעל לוח ציור"),
|
||||
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
|
||||
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "הפעל"),
|
||||
("Reuse one connection for port forwarding", "שימוש חוזר בחיבור אחד להעברת פורטים"),
|
||||
("port-forward-mux-tip", "כל החיבורים של העברת פורטים אחת עוברים דרך חיבור יחיד למחשב המרוחק, במקום ליצור חיבור חדש ולהיכנס מחדש עבור כל אחד מהם."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"),
|
||||
("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"),
|
||||
("Save as", "इस रूप में सहेजें"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "एक्सपोर्ट करें"),
|
||||
("Export Logs", "लॉग एक्सपोर्ट करें"),
|
||||
("Import Folder", "फ़ोल्डर इंपोर्ट करें"),
|
||||
("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"),
|
||||
("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"),
|
||||
("Downloading {}", "{} डाउनलोड हो रहा है"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "जारी रखें"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"),
|
||||
("Lock canvas", "कैनवास लॉक करें"),
|
||||
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
|
||||
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "सक्षम करें"),
|
||||
("Reuse one connection for port forwarding", "पोर्ट फ़ॉरवर्डिंग के लिए एक ही कनेक्शन दोबारा उपयोग करें"),
|
||||
("port-forward-mux-tip", "एक पोर्ट फ़ॉरवर्डिंग के सभी कनेक्शन दूसरे कंप्यूटर से बने एक ही कनेक्शन से होकर जाते हैं, हर एक के लिए दोबारा कनेक्ट और लॉगिन करने के बजाय।"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."),
|
||||
("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."),
|
||||
("Save as", "Spremi kao"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Izvoz"),
|
||||
("Export Logs", "Izvoz zapisnika"),
|
||||
("Import Folder", "Uvoz mape"),
|
||||
("Copy to clipboard", "Kopiraj u međuspremnik"),
|
||||
("Enable remote printer", "Omogući udaljeni pisač"),
|
||||
("Downloading {}", "Preuzimanje {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Nastavi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."),
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Omogući"),
|
||||
("Reuse one connection for port forwarding", "Ponovno koristi jednu vezu za prosljeđivanje portova"),
|
||||
("port-forward-mux-tip", "Sve veze jednog prosljeđivanja portova idu kroz jednu vezu prema drugoj strani, umjesto ponovnog povezivanja i prijave za svaku od njih."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"),
|
||||
("screenshot-action-tip", "Képernyőkép-művelet"),
|
||||
("Save as", "Mentés másként"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportálás"),
|
||||
("Export Logs", "Naplók exportálása"),
|
||||
("Import Folder", "Mappa importálása"),
|
||||
("Copy to clipboard", "Másolás a vágólapra"),
|
||||
("Enable remote printer", "Távoli nyomtatók engedélyezése"),
|
||||
("Downloading {}", "{} letöltése"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Folytatás"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."),
|
||||
("Lock canvas", "Nézet zárolása"),
|
||||
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
|
||||
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Engedélyezés"),
|
||||
("Reuse one connection for port forwarding", "Egyetlen kapcsolat újrafelhasználása a portátirányításhoz"),
|
||||
("port-forward-mux-tip", "Egy portátirányítás összes kapcsolatát egyetlen, a másik géppel létesített kapcsolaton vezeti át, ahelyett hogy mindegyikhez újra csatlakozna és bejelentkezne."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."),
|
||||
("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."),
|
||||
("Save as", "Simpan sebagai"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Ekspor"),
|
||||
("Export Logs", "Ekspor Log"),
|
||||
("Import Folder", "Impor Folder"),
|
||||
("Copy to clipboard", "Salin ke papan klip"),
|
||||
("Enable remote printer", "Aktifkan printer jarak jauh"),
|
||||
("Downloading {}", "Mendownload {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Lanjutkan"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."),
|
||||
("Lock canvas", "Kunci kanvas"),
|
||||
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
|
||||
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktifkan"),
|
||||
("Reuse one connection for port forwarding", "Gunakan ulang satu koneksi untuk penerusan port"),
|
||||
("port-forward-mux-tip", "Menyalurkan semua koneksi dari satu penerusan port melalui satu koneksi ke perangkat lain, alih-alih menyambung dan masuk lagi untuk setiap koneksi."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."),
|
||||
("screenshot-action-tip", "Seleziona come continuare con la schermata."),
|
||||
("Save as", "Salva come"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Esporta"),
|
||||
("Export Logs", "Esporta i log"),
|
||||
("Import Folder", "Importa cartella"),
|
||||
("Copy to clipboard", "Copia negli appunti"),
|
||||
("Enable remote printer", "Abilita stampante remota"),
|
||||
("Downloading {}", "Download {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continua"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."),
|
||||
("Lock canvas", "Blocca tela"),
|
||||
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
|
||||
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Abilita"),
|
||||
("Reuse one connection for port forwarding", "Riutilizza una sola connessione per l'inoltro delle porte"),
|
||||
("port-forward-mux-tip", "Fa passare tutte le connessioni di un inoltro di porte su un'unica connessione verso il dispositivo remoto, invece di connettersi e autenticarsi di nuovo per ognuna."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"),
|
||||
("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"),
|
||||
("Save as", "保存先"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "エクスポート"),
|
||||
("Export Logs", "ログをエクスポート"),
|
||||
("Import Folder", "フォルダをインポート"),
|
||||
("Copy to clipboard", "クリップボードにコピー"),
|
||||
("Enable remote printer", "リモートプリンターを有効化する"),
|
||||
("Downloading {}", "{} をダウンロード中"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "続行"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"),
|
||||
("Lock canvas", "キャンバスをロック"),
|
||||
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
|
||||
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "有効にする"),
|
||||
("Reuse one connection for port forwarding", "ポート転送で 1 つの接続を再利用する"),
|
||||
("port-forward-mux-tip", "1 つのポート転送のすべての接続を、相手への 1 本の接続にまとめます。接続ごとに接続とログインをやり直しません。"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Screen Share", "화면 공유"),
|
||||
("ubuntu-21-04-required", "Wayland는 Ubuntu 21.04 이상 버전이 필요합니다."),
|
||||
("wayland-requires-higher-linux-version", "Wayland는 상위 버전의 Linux 배포판이 필요합니다. X11 데스크탑을 사용하거나 OS를 변경하세요."),
|
||||
("xdp-portal-unavailable", ""),
|
||||
("xdp-portal-unavailable", "Wayland 화면 캡처에 실패했습니다. XDG Desktop Portal이 중단되었거나 사용할 수 없습니다. `systemctl --user restart xdg-desktop-portal` 명령으로 다시 시작해 보세요."),
|
||||
("JumpLink", "점프 링크"),
|
||||
("Please Select the screen to be shared(Operate on the peer side).", "공유할 화면을 선택하세요 (피어 측에서 작동)"),
|
||||
("Show RustDesk", "RustDesk 표시"),
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."),
|
||||
("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."),
|
||||
("Save as", "다른 이름으로 저장"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "내보내기"),
|
||||
("Export Logs", "로그 내보내기"),
|
||||
("Import Folder", "폴더 가져오기"),
|
||||
("Copy to clipboard", "클립보드에 복사"),
|
||||
("Enable remote printer", "원격 프린터 허용"),
|
||||
("Downloading {}", "{} 다운로드 중"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "계속"),
|
||||
("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."),
|
||||
("Lock canvas", "캔버스 잠금"),
|
||||
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
|
||||
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "활성화"),
|
||||
("Reuse one connection for port forwarding", "포트 포워딩에 연결 하나를 재사용"),
|
||||
("port-forward-mux-tip", "포트 포워딩 하나의 모든 연결을 상대방과의 단일 연결로 전달합니다. 연결마다 다시 접속하고 로그인하지 않습니다."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."),
|
||||
("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."),
|
||||
("Save as", "Басқаша сақтау"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Экспорттау"),
|
||||
("Export Logs", "Журналдарды экспорттау"),
|
||||
("Import Folder", "Қалтаны импорттау"),
|
||||
("Copy to clipboard", "Көшіру-тақтаға көшіру"),
|
||||
("Enable remote printer", "Қашықтағы принтерді іске қосу"),
|
||||
("Downloading {}", "{} жүктелуде"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Жалғастыру"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."),
|
||||
("Lock canvas", "Кенепті құлыптау"),
|
||||
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
|
||||
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Қосу"),
|
||||
("Reuse one connection for port forwarding", "Порт бағыттау үшін бір қосылымды қайта пайдалану"),
|
||||
("port-forward-mux-tip", "Бір порт бағыттаудың барлық қосылымдары әрқайсысы үшін қайта қосылып кірудің орнына қарсы құрылғымен орнатылған бір қосылым арқылы өтеді."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."),
|
||||
("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."),
|
||||
("Save as", "Įrašyti kaip"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksportuoti"),
|
||||
("Export Logs", "Eksportuoti žurnalus"),
|
||||
("Import Folder", "Importuoti aplanką"),
|
||||
("Copy to clipboard", "Kopijuoti į iškarpinę"),
|
||||
("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"),
|
||||
("Downloading {}", "Atsisiunčiama {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Tęsti"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."),
|
||||
("Lock canvas", "Užrakinti drobę"),
|
||||
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
|
||||
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Įgalinti"),
|
||||
("Reuse one connection for port forwarding", "Prievadų peradresavimui naudoti vieną ryšį"),
|
||||
("port-forward-mux-tip", "Visi vieno prievadų peradresavimo ryšiai eina per vieną ryšį su kitu kompiuteriu, užuot kiekvienam iš jų jungiantis ir prisijungiant iš naujo."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."),
|
||||
("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."),
|
||||
("Save as", "Saglabāt kā"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksportēt"),
|
||||
("Export Logs", "Eksportēt žurnālus"),
|
||||
("Import Folder", "Importēt mapi"),
|
||||
("Copy to clipboard", "Kopēt starpliktuvē"),
|
||||
("Enable remote printer", "Iespējot attālo printeri"),
|
||||
("Downloading {}", "Notiek {} lejupielāde"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Turpināt"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."),
|
||||
("Lock canvas", "Bloķēt audeklu"),
|
||||
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
|
||||
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Iespējot"),
|
||||
("Reuse one connection for port forwarding", "Atkārtoti izmantot vienu savienojumu portu pārsūtīšanai"),
|
||||
("port-forward-mux-tip", "Visi viena portu pārsūtījuma savienojumi tiek novadīti pa vienu savienojumu ar otru datoru, nevis katram no tiem izveidojot jaunu savienojumu un pieteikšanos."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."),
|
||||
("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"),
|
||||
("Save as", "പേരിൽ സേവ് ചെയ്യുക"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "എക്സ്പോർട്ട് ചെയ്യുക"),
|
||||
("Export Logs", "ലോഗുകൾ എക്സ്പോർട്ട് ചെയ്യുക"),
|
||||
("Import Folder", "ഫോൾഡർ ഇംപോർട്ട് ചെയ്യുക"),
|
||||
("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"),
|
||||
("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"),
|
||||
("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "തുടരുക"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."),
|
||||
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
|
||||
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
|
||||
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "അനുവദിക്കുക"),
|
||||
("Reuse one connection for port forwarding", "പോർട്ട് ഫോർവേഡിംഗിന് ഒരേ കണക്ഷൻ വീണ്ടും ഉപയോഗിക്കുക"),
|
||||
("port-forward-mux-tip", "ഒരു പോർട്ട് ഫോർവേഡിംഗിന്റെ എല്ലാ കണക്ഷനുകളും മറ്റേ കമ്പ്യൂട്ടറിലേക്കുള്ള ഒരൊറ്റ കണക്ഷനിലൂടെ കടന്നുപോകുന്നു, ഓരോന്നിനും വീണ്ടും കണക്റ്റ് ചെയ്ത് ലോഗിൻ ചെയ്യുന്നതിനു പകരം."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."),
|
||||
("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."),
|
||||
("Save as", "Lagre som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksporter"),
|
||||
("Export Logs", "Eksporter logger"),
|
||||
("Import Folder", "Importer mappe"),
|
||||
("Copy to clipboard", "Kopier til utklipstavlen"),
|
||||
("Enable remote printer", "Aktiver fjernskriver"),
|
||||
("Downloading {}", "Laster ned {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Fortsett"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."),
|
||||
("Lock canvas", "Lås lerret"),
|
||||
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktiver"),
|
||||
("Reuse one connection for port forwarding", "Gjenbruk én tilkobling for portvideresending"),
|
||||
("port-forward-mux-tip", "Fører alle tilkoblinger i en portvideresending gjennom én enkelt tilkobling til motparten i stedet for å koble til og logge inn på nytt for hver enkelt."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
|
||||
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
|
||||
("Save as", "Opslaan als"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exporteren"),
|
||||
("Export Logs", "Logboeken exporteren"),
|
||||
("Import Folder", "Map importeren"),
|
||||
("Copy to clipboard", "Kopiëren naar het klembord"),
|
||||
("Enable remote printer", "Printer op afstand inschakelen"),
|
||||
("Downloading {}", "Downloaden {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Doorgaan"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."),
|
||||
("Lock canvas", "Canvas vergrendelen"),
|
||||
("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Inschakelen"),
|
||||
("Reuse one connection for port forwarding", "Eén verbinding hergebruiken voor poortdoorschakeling"),
|
||||
("port-forward-mux-tip", "Alle verbindingen van een poortdoorschakeling via één enkele verbinding met de andere computer laten lopen, in plaats van voor elke verbinding opnieuw verbinding te maken en in te loggen."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."),
|
||||
("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."),
|
||||
("Save as", "Zapisz jako"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Eksportuj"),
|
||||
("Export Logs", "Eksportuj dzienniki"),
|
||||
("Import Folder", "Importuj folder"),
|
||||
("Copy to clipboard", "Kopiuj do schowka"),
|
||||
("Enable remote printer", "Włącz zdalne drukowanie"),
|
||||
("Downloading {}", "Pobieranie {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Kontynuuj"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Przeglądarka się nie otworzyła? Użyj poniższego adresu URL, aby się zalogować."),
|
||||
("Lock canvas", "Zablokuj ekran"),
|
||||
("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Włącz"),
|
||||
("Reuse one connection for port forwarding", "Użyj ponownie jednego połączenia do przekierowania portów"),
|
||||
("port-forward-mux-tip", "Przekazuj wszystkie połączenia jednego przekierowania portów przez jedno połączenie ze zdalnym komputerem, zamiast łączyć się i logować od nowa dla każdego z nich."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."),
|
||||
("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."),
|
||||
("Save as", "Guardar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportar"),
|
||||
("Export Logs", "Exportar Registos"),
|
||||
("Import Folder", "Importar Pasta"),
|
||||
("Copy to clipboard", "Copiar para a área de transferência"),
|
||||
("Enable remote printer", "Ativar impressora remota"),
|
||||
("Downloading {}", "A transferir {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuar"),
|
||||
("Browser didn't open? Use the url below to sign in.", "O navegador não abriu? Utilize o URL abaixo para iniciar sessão."),
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ativar"),
|
||||
("Reuse one connection for port forwarding", "Reutilizar uma ligação para o reencaminhamento de portas"),
|
||||
("port-forward-mux-tip", "Encaminhar todas as ligações de um reencaminhamento de portas por uma única ligação ao outro computador, em vez de ligar e iniciar sessão novamente para cada uma."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
||||
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
||||
("Save as", "Salvar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportar"),
|
||||
("Export Logs", "Exportar logs"),
|
||||
("Import Folder", "Importar pasta"),
|
||||
("Copy to clipboard", "Copiar para área de transferência"),
|
||||
("Enable remote printer", "Habilitar impressora remota"),
|
||||
("Downloading {}", "Baixando {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuar"),
|
||||
("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."),
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilitar"),
|
||||
("Reuse one connection for port forwarding", "Reutilizar uma conexão para encaminhamento de portas"),
|
||||
("port-forward-mux-tip", "Levar todas as conexões de um encaminhamento de portas por uma única conexão com o outro computador, em vez de conectar e fazer login novamente para cada uma."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."),
|
||||
("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."),
|
||||
("Save as", "Salvează ca"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportă"),
|
||||
("Export Logs", "Exportă jurnalele"),
|
||||
("Import Folder", "Importă folder"),
|
||||
("Copy to clipboard", "Copiază în clipboard"),
|
||||
("Enable remote printer", "Activează imprimanta la distanță"),
|
||||
("Downloading {}", "Se descarcă {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuă"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Browserul nu s-a deschis? Folosește URL-ul de mai jos pentru a te conecta."),
|
||||
("Lock canvas", "Blochează ecranul"),
|
||||
("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"),
|
||||
("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Activează"),
|
||||
("Reuse one connection for port forwarding", "Reutilizează o singură conexiune pentru redirecționarea porturilor"),
|
||||
("port-forward-mux-tip", "Trece toate conexiunile unei redirecționări de porturi printr-o singură conexiune către celălalt calculator, în loc să se conecteze și să se autentifice din nou pentru fiecare."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."),
|
||||
("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."),
|
||||
("Save as", "Сохранить в файл"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Экспортировать"),
|
||||
("Export Logs", "Экспортировать журналы"),
|
||||
("Import Folder", "Импортировать папку"),
|
||||
("Copy to clipboard", "Копировать в буфер обмена"),
|
||||
("Enable remote printer", "Использовать удалённый принтер"),
|
||||
("Downloading {}", "Скачивание"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Продолжить"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браузер не открылся? Используйте ссылку ниже для входа."),
|
||||
("Lock canvas", "Заблокировать холст"),
|
||||
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Включить"),
|
||||
("Reuse one connection for port forwarding", "Использовать одно подключение для перенаправления портов"),
|
||||
("port-forward-mux-tip", "Передавать все соединения одного перенаправления портов через одно подключение к удалённому устройству вместо повторного подключения и входа для каждого из них."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."),
|
||||
("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."),
|
||||
("Save as", "Sarva comente"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Esporta"),
|
||||
("Export Logs", "Esporta is registros"),
|
||||
("Import Folder", "Importa cartella"),
|
||||
("Copy to clipboard", "Còpia in punta de billete"),
|
||||
("Enable remote printer", "Abìlita imprentadora remota"),
|
||||
("Downloading {}", "Iscarrighende {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Sighi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Non s'est abertu su navigadore? Imprea s'URL inoghe in suta pro intrare."),
|
||||
("Lock canvas", "Bloca sa tela"),
|
||||
("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"),
|
||||
("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Abìlita"),
|
||||
("Reuse one connection for port forwarding", "Torra a impreare una connessione pro s'imbiu de is portas"),
|
||||
("port-forward-mux-tip", "Totu is connessiones de un'imbiu de portas passant in una connessione ebbia a s'àteru computadore, in logu de si connètere e intrare torra pro dontzi una."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."),
|
||||
("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."),
|
||||
("Save as", "Uložiť ako"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportovať"),
|
||||
("Export Logs", "Exportovať protokoly"),
|
||||
("Import Folder", "Importovať priečinok"),
|
||||
("Copy to clipboard", "Kopírovať do schránky"),
|
||||
("Enable remote printer", "Povoliť vzdialenú tlačiareň"),
|
||||
("Downloading {}", "Sťahuje sa {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Pokračovať"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Neotvoril sa prehliadač? Na prihlásenie použite URL nižšie."),
|
||||
("Lock canvas", "Uzamknúť zobrazenie"),
|
||||
("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"),
|
||||
("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Povoliť"),
|
||||
("Reuse one connection for port forwarding", "Znovu použiť jedno pripojenie na presmerovanie portov"),
|
||||
("port-forward-mux-tip", "Vedie všetky pripojenia jedného presmerovania portov cez jediné pripojenie k druhej strane namiesto opakovaného pripájania a prihlasovania pre každé z nich."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."),
|
||||
("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."),
|
||||
("Save as", "Shrani kot"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Izvozi"),
|
||||
("Export Logs", "Izvozi dnevnike"),
|
||||
("Import Folder", "Uvozi mapo"),
|
||||
("Copy to clipboard", "Kopiraj v odložišče"),
|
||||
("Enable remote printer", "Omogoči oddaljeni tiskalnik"),
|
||||
("Downloading {}", "Prenašanje {}"),
|
||||
@@ -761,5 +761,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Nadaljuj"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Brskalnik se ni odprl? Za prijavo uporabite spodnji URL."),
|
||||
("Lock canvas", "Zakleni platno"),
|
||||
("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"),
|
||||
("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Omogoči"),
|
||||
("Reuse one connection for port forwarding", "Ponovno uporabi eno povezavo za posredovanje vrat"),
|
||||
("port-forward-mux-tip", "Vse povezave enega posredovanja vrat potekajo prek ene same povezave do druge strani, namesto ponovnega povezovanja in prijave za vsako od njih."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user