mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-16 17:31:04 +03:00
Compare commits
12 Commits
dxgi-reuse
...
0fd1a0eecb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd | ||
|
|
f28ac38ccf | ||
|
|
28cf1836e6 | ||
|
|
1ec1b9e7e3 | ||
|
|
2c84c8fb13 | ||
|
|
66ab0b87f6 | ||
|
|
169f74f8d9 | ||
|
|
d4b06a6c5c | ||
|
|
03a7fc5992 |
72
.github/workflows/flutter-build.yml
vendored
72
.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 }}"
|
||||
@@ -44,7 +44,7 @@ env:
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
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 +389,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 +973,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
|
||||
|
||||
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
|
||||
|
||||
4
.github/workflows/update-webpki-roots.yml
vendored
4
.github/workflows/update-webpki-roots.yml
vendored
@@ -33,6 +33,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# The root workspace lists libs/hbb_common as a member; without the
|
||||
# submodule its manifest is missing and cargo cannot load the workspace.
|
||||
submodules: recursive
|
||||
|
||||
- name: Update webpki-roots in all lockfiles
|
||||
id: update
|
||||
|
||||
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'
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.carriez.flutter_hbb">
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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" />
|
||||
@@ -26,7 +30,6 @@
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true">
|
||||
|
||||
@@ -88,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() {
|
||||
|
||||
@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import ffi.FFI
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.json.JSONArray
|
||||
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.concurrent.thread
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
|
||||
private val channelTag = "mChannel"
|
||||
private val logTag = "mMainActivity"
|
||||
private var mainService: MainService? = null
|
||||
private sealed class PendingPicker {
|
||||
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFiles(
|
||||
val sources: List<File>,
|
||||
val rejected: Int,
|
||||
val result: MethodChannel.Result
|
||||
) : PendingPicker()
|
||||
}
|
||||
|
||||
private data class ExportSource(
|
||||
val file: File,
|
||||
val children: List<ExportSource>?
|
||||
)
|
||||
|
||||
private var pendingPicker: PendingPicker? = null
|
||||
|
||||
private var isAudioStart = false
|
||||
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
|
||||
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ_IMPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
|
||||
pendingPicker = null
|
||||
if (resultCode != Activity.RESULT_OK || data == null) {
|
||||
pending.result.success(emptyList<Map<String, String>>())
|
||||
return
|
||||
}
|
||||
|
||||
val uris = linkedSetOf<Uri>()
|
||||
data.data?.let { uris.add(it) }
|
||||
data.clipData?.let { clipData ->
|
||||
for (index in 0 until clipData.itemCount) {
|
||||
uris.add(clipData.getItemAt(index).uri)
|
||||
}
|
||||
}
|
||||
thread {
|
||||
val files = uris.map { uri ->
|
||||
mapOf(
|
||||
"uri" to uri.toString(),
|
||||
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
|
||||
)
|
||||
}
|
||||
runOnUiThread { pending.result.success(files) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILE) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
|
||||
pendingPicker = null
|
||||
val destination = data?.data
|
||||
|
||||
if (resultCode != Activity.RESULT_OK || destination == null) {
|
||||
pending.result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
thread {
|
||||
try {
|
||||
FileInputStream(pending.source).use { input ->
|
||||
contentResolver.openOutputStream(destination, "wt")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("Unable to open the selected destination")
|
||||
}
|
||||
runOnUiThread { pending.result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file", e)
|
||||
runOnUiThread {
|
||||
pending.result.error("export_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_IMPORT_DIRECTORY) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
val selected = mapOf(
|
||||
"uri" to treeUri.toString(),
|
||||
"name" to (treeDisplayName(treeUri) ?: "Imported")
|
||||
)
|
||||
runOnUiThread { pending.result.success(selected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
var exported = 0
|
||||
var failed = pending.rejected
|
||||
var processed = 0
|
||||
try {
|
||||
val sources = pending.sources.map { snapshotExportSource(it) }
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
sources.forEach { source ->
|
||||
val ok = source?.let {
|
||||
copyExportSourceToTree(treeUri, rootDocId, it)
|
||||
} ?: false
|
||||
if (ok) exported++ else failed++
|
||||
processed++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export selected files", e)
|
||||
failed += pending.sources.size - processed
|
||||
}
|
||||
runOnUiThread {
|
||||
pending.result.success(mapOf("exported" to exported, "failed" to failed))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
|
||||
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
|
||||
}
|
||||
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_FILES -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportFiles(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
},
|
||||
REQ_IMPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_FILE -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.isDirectory ||
|
||||
destination.parentFile?.isDirectory != true) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var reservedDestination = false
|
||||
var errorCode = "import_failed"
|
||||
try {
|
||||
val temporaryFile = File.createTempFile(
|
||||
".rustdesk-import-",
|
||||
".tmp",
|
||||
destination.parentFile
|
||||
)
|
||||
temporary = temporaryFile
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(temporaryFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open the selected document")
|
||||
if (!overwrite) {
|
||||
reservedDestination = destination.createNewFile()
|
||||
if (!reservedDestination) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
}
|
||||
if (!temporaryFile.renameTo(destination)) {
|
||||
if (reservedDestination) {
|
||||
destination.delete()
|
||||
}
|
||||
errorCode = "rename_failed"
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
runOnUiThread { result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import file", e)
|
||||
runOnUiThread {
|
||||
result.error(errorCode, e.message, null)
|
||||
}
|
||||
} finally {
|
||||
temporary?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILE -> {
|
||||
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
|
||||
val source = path?.let { canonicalExportSource(it) }
|
||||
|
||||
if (source?.isFile != true) {
|
||||
result.error("invalid_source", "The file is outside app-scoped storage", null)
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
val mimeType = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(source.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
pendingPicker = PendingPicker.ExportFile(source, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, source.name)
|
||||
},
|
||||
REQ_EXPORT_FILE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_DIRECTORY -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportDirectory(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
|
||||
},
|
||||
REQ_IMPORT_DIRECTORY
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_DIRECTORY -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.parentFile?.isDirectory != true ||
|
||||
(destination.exists() && !destination.isDirectory)) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var backup: File? = null
|
||||
val ok = try {
|
||||
val parent = destination.parentFile
|
||||
?: throw IllegalStateException("The destination has no parent")
|
||||
temporary = File.createTempFile(
|
||||
".rustdesk-import-dir-",
|
||||
".tmp",
|
||||
parent
|
||||
).also {
|
||||
if (!it.delete() || !it.mkdir()) {
|
||||
throw IllegalStateException("Unable to create a temporary folder")
|
||||
}
|
||||
}
|
||||
if (!copyDocumentTreeToFile(uri, temporary!!)) {
|
||||
throw IllegalStateException("Unable to read all folder contents")
|
||||
}
|
||||
if (destination.exists()) {
|
||||
if (!overwrite) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
val backupFile = File.createTempFile(
|
||||
".rustdesk-import-backup-",
|
||||
".tmp",
|
||||
parent
|
||||
)
|
||||
if (!backupFile.delete()) {
|
||||
throw IllegalStateException("Unable to prepare the destination backup")
|
||||
}
|
||||
backup = backupFile
|
||||
if (!destination.renameTo(backupFile)) {
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
}
|
||||
if (!temporary!!.renameTo(destination)) {
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.renameTo(destination)
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to move the imported folder and restore " +
|
||||
"the destination from $destinationBackup"
|
||||
)
|
||||
}
|
||||
throw IllegalStateException("Unable to move the imported folder")
|
||||
}
|
||||
temporary = null
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.deleteRecursively()
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to remove the destination backup: $destinationBackup"
|
||||
)
|
||||
}
|
||||
backup = null
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import directory", e)
|
||||
false
|
||||
} finally {
|
||||
temporary?.deleteRecursively()
|
||||
}
|
||||
runOnUiThread { result.success(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILES -> {
|
||||
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
|
||||
if (paths.isNullOrEmpty()) {
|
||||
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
|
||||
} else {
|
||||
val sources = paths.mapNotNull {
|
||||
(it as? String)?.let(::canonicalExportSource)
|
||||
}
|
||||
val rejected = paths.size - sources.size
|
||||
if (sources.isEmpty()) {
|
||||
result.success(mapOf("exported" to 0, "failed" to rejected))
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
|
||||
},
|
||||
REQ_EXPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET_VALUE -> {
|
||||
if (call.arguments is String) {
|
||||
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
|
||||
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalAppScopedFile(path: String): File? {
|
||||
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
|
||||
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
|
||||
runCatching { it.canonicalFile }.getOrNull()
|
||||
}
|
||||
return file.takeIf { candidate ->
|
||||
allowedRoots.any { root ->
|
||||
candidate == root || candidate.path.startsWith(root.path + File.separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalExportSource(path: String): File? {
|
||||
val original = File(path).absoluteFile
|
||||
val canonical = canonicalAppScopedFile(path) ?: return null
|
||||
return canonical.takeIf {
|
||||
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotExportSource(source: File): ExportSource? {
|
||||
val safeSource = canonicalExportSource(source.path) ?: return null
|
||||
if (safeSource.isFile) return ExportSource(safeSource, null)
|
||||
val sourceChildren = safeSource.listFiles() ?: return null
|
||||
val children = ArrayList<ExportSource>(sourceChildren.size)
|
||||
for (child in sourceChildren) {
|
||||
val snapshot = snapshotExportSource(child) ?: return null
|
||||
children.add(snapshot)
|
||||
}
|
||||
return ExportSource(safeSource, children)
|
||||
}
|
||||
|
||||
private fun copyExportSourceToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children
|
||||
return if (children == null) {
|
||||
copyFileToTree(treeUri, parentDocId, source.file)
|
||||
} else {
|
||||
copyDirToTree(treeUri, parentDocId, source)
|
||||
}
|
||||
}
|
||||
|
||||
private fun treeDisplayName(treeUri: Uri): String? {
|
||||
return try {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
|
||||
contentResolver.query(
|
||||
docUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected folder name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
|
||||
}
|
||||
|
||||
private fun copyChildrenToFile(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
destinationDir: File
|
||||
): Boolean {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
var ok = true
|
||||
val destinationNames = HashSet<String>()
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: return false
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
val docId = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val mime = cursor.getString(2)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
if (name != null && !destinationNames.add(name)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
val destination = safeDestinationChild(destinationDir, name)
|
||||
if (destination == null || destination.exists()) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!destination.mkdirs() && !destination.isDirectory) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (!copyChildrenToFile(treeUri, docId, destination)) {
|
||||
ok = false
|
||||
}
|
||||
} else if (!copyDocumentToFile(docUri, destination)) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
|
||||
if (name.isNullOrEmpty() || name == "." || name == ".." ||
|
||||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
|
||||
return null
|
||||
}
|
||||
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
|
||||
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
|
||||
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
|
||||
}
|
||||
|
||||
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
|
||||
return try {
|
||||
destination.parentFile?.mkdirs()
|
||||
if (destination.exists() && !destination.delete()) {
|
||||
return false
|
||||
}
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||
} != null
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to copy document to $destination", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
|
||||
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
|
||||
return try {
|
||||
val mime = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(safeSource.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
val docUri = DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
mime,
|
||||
safeSource.name
|
||||
) ?: return false
|
||||
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
|
||||
FileInputStream(safeSource).use { input -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file $safeSource", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children ?: return false
|
||||
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
|
||||
?: return false
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
|
||||
if (dirDocId == null) {
|
||||
dirDocId = try {
|
||||
DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
safeSource.name
|
||||
)?.let { DocumentsContract.getDocumentId(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
if (dirDocId == null) return false
|
||||
|
||||
var ok = true
|
||||
children.forEach { child ->
|
||||
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
|
||||
if (!childOk) ok = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: throw IllegalStateException("Unable to query destination folder")
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(1) == name &&
|
||||
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val childColumns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
|
||||
private fun displayName(uri: Uri): String? {
|
||||
return try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected document name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCodecInfo() {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val codecs = codecList.codecInfos
|
||||
|
||||
@@ -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,6 +215,19 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
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
|
||||
@@ -243,7 +257,9 @@ class MainService : Service() {
|
||||
// keep the config dir same with flutter
|
||||
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
|
||||
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
|
||||
FFI.startServer(configPath, "")
|
||||
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
|
||||
?: applicationContext.filesDir.absolutePath
|
||||
FFI.startServer(configPath, homePath, "")
|
||||
|
||||
createForegroundNotification()
|
||||
}
|
||||
@@ -337,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()
|
||||
}
|
||||
@@ -347,10 +361,7 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
mediaProjection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
checkMediaPermission()
|
||||
_isReady = true
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -364,14 +375,23 @@ 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)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun cancelMediaProjectionRecovery() {
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun createSurface(): Surface? {
|
||||
return if (useVP9) {
|
||||
@@ -405,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
|
||||
}
|
||||
@@ -421,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)
|
||||
@@ -447,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)
|
||||
@@ -480,8 +659,11 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -496,7 +678,9 @@ class MainService : Service() {
|
||||
virtualDisplay = null
|
||||
}
|
||||
|
||||
mediaProjection = null
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -519,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) {}
|
||||
@@ -652,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,11 +33,16 @@ 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
|
||||
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
|
||||
const val REQ_REQUEST_MEDIA_PROJECTION = 201
|
||||
const val REQ_EXPORT_FILE = 301
|
||||
const val REQ_IMPORT_FILES = 302
|
||||
const val REQ_IMPORT_DIRECTORY = 303
|
||||
const val REQ_EXPORT_FILES = 304
|
||||
|
||||
// Activity responseCode
|
||||
const val RES_FAILED = -100
|
||||
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
|
||||
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
|
||||
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
|
||||
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
|
||||
const val PICK_IMPORT_FILES = "pick_import_files"
|
||||
const val IMPORT_FILE = "import_file"
|
||||
const val EXPORT_FILE = "export_file"
|
||||
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
|
||||
const val IMPORT_DIRECTORY = "import_directory"
|
||||
const val EXPORT_FILES = "export_files"
|
||||
const val GET_VALUE = "get_value"
|
||||
|
||||
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
|
||||
@@ -154,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ object FFI {
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
external fun onVideoFrameUpdate(buf: ByteBuffer)
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1519,13 +1519,6 @@ class AndroidPermissionManager {
|
||||
static Timer? _timer;
|
||||
static var _current = "";
|
||||
|
||||
static bool isWaitingFile() {
|
||||
if (_completer != null) {
|
||||
return !_completer!.isCompleted && _current == kManageExternalStorage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> check(String type) {
|
||||
if (isDesktop || isWeb) {
|
||||
return Future.value(true);
|
||||
@@ -2634,13 +2627,6 @@ connect(BuildContext context, String id,
|
||||
}
|
||||
} else {
|
||||
if (isFileTransfer) {
|
||||
if (isAndroid) {
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -168,6 +168,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";
|
||||
@@ -439,7 +441,6 @@ const kActionApplicationDetailsSettings =
|
||||
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
|
||||
|
||||
const kRecordAudio = "android.permission.RECORD_AUDIO";
|
||||
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
|
||||
const kRequestIgnoreBatteryOptimizations =
|
||||
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
|
||||
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
|
||||
@@ -451,6 +452,12 @@ class AndroidChannel {
|
||||
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
|
||||
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
|
||||
static final kSyncAppDirConfigPath = "sync_app_dir";
|
||||
static final kPickImportFiles = "pick_import_files";
|
||||
static final kImportFile = "import_file";
|
||||
static final kExportFile = "export_file";
|
||||
static final kPickImportDirectory = "pick_import_directory";
|
||||
static final kImportDirectory = "import_directory";
|
||||
static final kExportFiles = "export_files";
|
||||
}
|
||||
|
||||
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
||||
|
||||
@@ -575,6 +575,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
|
||||
gFFI.ffiModel.beginAndroidDocumentPicker();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
gFFI.ffiModel.endAndroidDocumentPicker();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFiles() async {
|
||||
var imported = 0;
|
||||
var failed = false;
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final selectedFiles = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<List<dynamic>>(
|
||||
AndroidChannel.kPickImportFiles));
|
||||
if (selectedFiles == null || selectedFiles.isEmpty) return;
|
||||
|
||||
for (final selected in selectedFiles) {
|
||||
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
|
||||
final selectedName = selected['name'] as String?;
|
||||
final name = selectedName?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
final destination =
|
||||
PathUtil.join(importDirectory, name, importIsWindows);
|
||||
var overwrite = false;
|
||||
if (await File(destination).exists()) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult == false) break;
|
||||
if (overwriteResult != true) continue;
|
||||
overwrite = true;
|
||||
}
|
||||
try {
|
||||
final success = await gFFI.invokeMethod(
|
||||
AndroidChannel.kImportFile,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
imported++;
|
||||
} else {
|
||||
failed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to import $name: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to select files for import: $e');
|
||||
}
|
||||
await importController.refresh();
|
||||
if (failed) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (imported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportFile(Entry entry) async {
|
||||
try {
|
||||
final exported = await _runAndroidDocumentPicker(() => gFFI
|
||||
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
|
||||
if (exported == true) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export ${entry.name}: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFolder() async {
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final picked = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kPickImportDirectory));
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
final uri = picked['uri'] as String?;
|
||||
final name =
|
||||
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
name == '.' ||
|
||||
name == '..' ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final destination = PathUtil.join(importDirectory, name, importIsWindows);
|
||||
final destinationType = await FileSystemEntity.type(destination);
|
||||
var overwrite = false;
|
||||
if (destinationType == FileSystemEntityType.directory) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult != true) return;
|
||||
overwrite = true;
|
||||
} else if (destinationType != FileSystemEntityType.notFound) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
showToast(translate('Successful'));
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to import folder: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
await importController.refresh();
|
||||
}
|
||||
|
||||
Future<void> _exportItems(SelectedItems items) async {
|
||||
await _exportPaths(items.items.map((e) => e.path));
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
final home = currentFileController.homePath;
|
||||
if (home.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final appDir = PathUtil.join(home, appName, false);
|
||||
final paths = [
|
||||
PathUtil.join(appDir, 'Logs', false),
|
||||
PathUtil.join(appDir, 'ScreenRecord', false),
|
||||
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
|
||||
if (paths.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
await _exportPaths(paths);
|
||||
}
|
||||
|
||||
Future<void> _exportPaths(Iterable<String> paths) async {
|
||||
try {
|
||||
final result = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
|
||||
if (result == null) return;
|
||||
final exported = result['exported'] as int? ?? 0;
|
||||
final failed = result['failed'] as int? ?? 0;
|
||||
if (failed > 0) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (exported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export paths: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
),
|
||||
value: "refresh",
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add_to_drive,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Add"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import_folder",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.create_new_folder_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Import Folder"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "export_logs",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Export Logs"))
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
enabled: currentDir.path != "/",
|
||||
child: Row(
|
||||
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
onSelected: (v) {
|
||||
if (v == "refresh") {
|
||||
currentFileController.refresh();
|
||||
} else if (v == "import") {
|
||||
_importFiles();
|
||||
} else if (v == "import_folder") {
|
||||
_importFolder();
|
||||
} else if (v == "export_logs") {
|
||||
_exportLogs();
|
||||
} else if (v == "select") {
|
||||
model.localController.selectedItems.clear();
|
||||
model.remoteController.selectedItems.clear();
|
||||
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
setState(() {});
|
||||
},
|
||||
actions: [
|
||||
if (isAndroid &&
|
||||
selectedItems?.isLocal == true &&
|
||||
selectedItems?.items.isNotEmpty == true) ...[
|
||||
if (selectedItems!.items.length == 1 &&
|
||||
selectedItems!.items.single.isFile)
|
||||
IconButton(
|
||||
tooltip: translate("Save as"),
|
||||
icon: Icon(Icons.save_alt),
|
||||
onPressed: () =>
|
||||
_exportFile(selectedItems!.items.single),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: translate("Export"),
|
||||
icon: Icon(Icons.drive_folder_upload),
|
||||
onPressed: () => _exportItems(selectedItems!),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: Icon(Icons.compare_arrows),
|
||||
onPressed: () => setState(() => showLocal = !showLocal),
|
||||
|
||||
@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
|
||||
|
||||
void checkService() async {
|
||||
gFFI.invokeMethod("check_service");
|
||||
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
|
||||
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
|
||||
AndroidPermissionManager.complete(kManageExternalStorage,
|
||||
await AndroidPermissionManager.check(kManageExternalStorage));
|
||||
debugPrint("file permission finished");
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceNotRunningNotification extends StatelessWidget {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
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/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
@@ -190,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
platform: defaultTargetPlatform,
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
@@ -244,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(
|
||||
_terminalModel.terminal,
|
||||
_terminalModel.terminalController,
|
||||
fallback: _handleTerminalKeyEvent,
|
||||
),
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
|
||||
@@ -381,6 +381,14 @@ class FileController {
|
||||
void set homePath(String path) => options.value.home = path;
|
||||
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
|
||||
|
||||
bool _isPathAllowed(String candidate) {
|
||||
if (!isAndroid || !isLocal) return true;
|
||||
if (homePath.isEmpty || candidate.isEmpty) return false;
|
||||
final home = PathUtil.posixContext.normalize(homePath);
|
||||
final target = PathUtil.posixContext.normalize(candidate);
|
||||
return target == home || PathUtil.posixContext.isWithin(home, target);
|
||||
}
|
||||
|
||||
String get shortPath {
|
||||
final dirPath = directory.value.path;
|
||||
if (dirPath.startsWith(homePath)) {
|
||||
@@ -414,8 +422,13 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
var savedDir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
|
||||
savedDir = options.value.home;
|
||||
await bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: "local_dir", value: savedDir);
|
||||
}
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
@@ -485,6 +498,9 @@ class FileController {
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!_isPathAllowed(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -504,6 +520,7 @@ class FileController {
|
||||
return true;
|
||||
}
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
selectedItems.reconcile(fd.entries);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -550,6 +567,9 @@ class FileController {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
if (!_isPathAllowed(parent)) {
|
||||
return true;
|
||||
}
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
@@ -1885,7 +1905,7 @@ class PathUtil {
|
||||
}
|
||||
|
||||
static bool validName(String name, bool isWindows) {
|
||||
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
|
||||
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
|
||||
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
|
||||
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
|
||||
return reg.hasMatch(name);
|
||||
@@ -1928,6 +1948,21 @@ class SelectedItems {
|
||||
items.clear();
|
||||
}
|
||||
|
||||
void reconcile(List<Entry> entries) {
|
||||
if (items.isEmpty) return;
|
||||
final currentByPath = {for (final entry in entries) entry.path: entry};
|
||||
final reconciled = <Entry>[];
|
||||
for (final item in items) {
|
||||
final current = currentByPath[item.path];
|
||||
if (current != null && current.entryType == item.entryType) {
|
||||
reconciled.add(current);
|
||||
}
|
||||
}
|
||||
items
|
||||
..clear()
|
||||
..addAll(reconciled);
|
||||
}
|
||||
|
||||
void selectAll(List<Entry> entries) {
|
||||
items.clear();
|
||||
items.addAll(entries);
|
||||
|
||||
@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
/// xterm already handles each platform's paste shortcut in the common case.
|
||||
/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
|
||||
/// emit a one-character paste as normal text when bracketed paste mode is off.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required TargetPlatform platform,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
if (altPressed) return false;
|
||||
switch (platform) {
|
||||
case TargetPlatform.linux:
|
||||
return controlPressed && !metaPressed && shiftPressed;
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return !controlPressed && metaPressed && !shiftPressed;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.windows:
|
||||
return controlPressed && !metaPressed && !shiftPressed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
|
||||
@@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier {
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _androidDocumentPickerActive = false;
|
||||
bool _androidDocumentPickerInterruptedConnection = false;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_androidDocumentPickerActive = false;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
@@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier {
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
if (isAndroid &&
|
||||
_androidDocumentPickerActive &&
|
||||
title == 'Connection Error') {
|
||||
_androidDocumentPickerInterruptedConnection = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
@@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier {
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
void beginAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = true;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
}
|
||||
|
||||
void endAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = false;
|
||||
if (!_androidDocumentPickerInterruptedConnection ||
|
||||
parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
reconnect(parent.target!.dialogManager, sessionId, false);
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -4060,6 +4088,11 @@ class FFI {
|
||||
return await platformFFI.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
|
||||
}
|
||||
|
||||
// Terminal model management
|
||||
void registerTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -171,8 +170,10 @@ class PlatformFFI {
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
// Android file transfer uses app-specific storage. User-selected
|
||||
// files enter and leave this workspace through the system picker.
|
||||
_homeDir = (await getExternalStorageDirectory())?.path ??
|
||||
(await getApplicationSupportDirectory()).path;
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
@@ -306,6 +307,12 @@ class PlatformFFI {
|
||||
return await _toAndroidChannel.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
if (!isAndroid) return null;
|
||||
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
|
||||
}
|
||||
|
||||
void syncAndroidServiceAppDirConfigPath() {
|
||||
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
||||
}
|
||||
|
||||
@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
|
||||
_audioOk = audioOption != 'N';
|
||||
}
|
||||
|
||||
// file
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
_fileOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
|
||||
} else {
|
||||
final fileOption =
|
||||
await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
}
|
||||
// Android file transfer is confined to app-specific storage. Files enter
|
||||
// and leave the workspace through Android's system document picker.
|
||||
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
final res =
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_fileOk = !_fileOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableFileTransfer,
|
||||
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
|
||||
await checkFloatingWindowPermission();
|
||||
}
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
}
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
|
||||
@@ -20,43 +20,68 @@ Future<void> writeTerminalClipboard(String text) async {
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
if (defaultTargetPlatform != TargetPlatform.linux) return null;
|
||||
final platform = defaultTargetPlatform;
|
||||
if (platform == TargetPlatform.linux) {
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
|
||||
entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
};
|
||||
}
|
||||
if (platform != TargetPlatform.windows &&
|
||||
platform != TargetPlatform.android) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlVShortcut(entry.key)) entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
if (!_isControlShortcut(
|
||||
entry.key,
|
||||
LogicalKeyboardKey.keyC,
|
||||
shift: true,
|
||||
))
|
||||
entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
bool _isControlVShortcut(ShortcutActivator shortcut) =>
|
||||
bool _isControlShortcut(
|
||||
ShortcutActivator shortcut,
|
||||
LogicalKeyboardKey key, {
|
||||
bool shift = false,
|
||||
}) =>
|
||||
shortcut is SingleActivator &&
|
||||
shortcut.trigger == LogicalKeyboardKey.keyV &&
|
||||
shortcut.trigger == key &&
|
||||
shortcut.control &&
|
||||
!shortcut.shift &&
|
||||
shortcut.shift == shift &&
|
||||
!shortcut.alt &&
|
||||
!shortcut.meta;
|
||||
|
||||
FocusOnKeyEventCallback terminalCopyHandler(
|
||||
Terminal terminal,
|
||||
TerminalController controller,
|
||||
) =>
|
||||
(_, event) {
|
||||
if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored;
|
||||
final selection = controller.selection;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return KeyEventResult.ignored;
|
||||
TerminalController controller, {
|
||||
FocusOnKeyEventCallback? fallback,
|
||||
}) =>
|
||||
(focusNode, event) {
|
||||
if (_isSelectionCopyShortcut(event)) {
|
||||
final selection = controller.selection;
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
bool _isWindowsCopyShortcut(KeyEvent event) {
|
||||
bool _isSelectionCopyShortcut(KeyEvent event) {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
return defaultTargetPlatform == TargetPlatform.windows &&
|
||||
final platform = defaultTargetPlatform;
|
||||
final usesControlCopy =
|
||||
platform == TargetPlatform.windows || platform == TargetPlatform.android;
|
||||
return usesControlCopy &&
|
||||
(event is KeyDownEvent || event is KeyRepeatEvent) &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
keyboard.isControlPressed &&
|
||||
|
||||
@@ -251,6 +251,11 @@ class PlatformFFI {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
|
||||
@@ -409,14 +409,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
external_path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: external_path
|
||||
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -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'
|
||||
@@ -29,7 +29,6 @@ dependencies:
|
||||
|
||||
ffi: ^2.1.0
|
||||
path_provider: ^2.1.1
|
||||
external_path: ^1.0.3
|
||||
provider: ^6.0.5
|
||||
tuple: ^2.0.0
|
||||
wakelock_plus: ^1.1.3
|
||||
|
||||
@@ -342,11 +342,43 @@ void main() {
|
||||
});
|
||||
|
||||
group('shouldHandleTerminalPasteShortcut', () {
|
||||
test('handles only Ctrl+Shift+V on Linux with a virtual lock', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: true,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default xterm paste behavior when virtual modifiers are inactive',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -364,6 +396,7 @@ void main() {
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -377,6 +410,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.macOS,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -393,6 +427,7 @@ void main() {
|
||||
test('handles paste shortcut repeats while a virtual lock is active', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: true,
|
||||
@@ -409,6 +444,7 @@ void main() {
|
||||
test('ignores key-up and unmodified V events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: false,
|
||||
@@ -422,6 +458,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -444,6 +481,7 @@ void main() {
|
||||
]) {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -461,6 +499,7 @@ void main() {
|
||||
test('ignores non-V key events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyC,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ pub struct Capturer {
|
||||
duplication: ComPtr<IDXGIOutputDuplication>,
|
||||
fastlane: bool,
|
||||
surface: ComPtr<IDXGISurface>,
|
||||
readable: ComPtr<ID3D11Texture2D>,
|
||||
texture: ComPtr<ID3D11Texture2D>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
@@ -164,7 +163,6 @@ impl Capturer {
|
||||
duplication: ComPtr(duplication),
|
||||
fastlane: desc.DesktopImageInSystemMemory == TRUE,
|
||||
surface: ComPtr(ptr::null_mut()),
|
||||
readable: ComPtr(ptr::null_mut()),
|
||||
texture: ComPtr(ptr::null_mut()),
|
||||
width: display.width() as usize,
|
||||
height: display.height() as usize,
|
||||
@@ -348,19 +346,19 @@ impl Capturer {
|
||||
if self.fastlane {
|
||||
wrap_hresult((*self.duplication.0).MapDesktopSurface(&mut rect))?;
|
||||
} else {
|
||||
self.ohgodwhat(frame.0)?;
|
||||
self.surface = ComPtr(self.ohgodwhat(frame.0)?);
|
||||
wrap_hresult((*self.surface.0).Map(&mut rect, DXGI_MAP_READ))?;
|
||||
}
|
||||
Ok((rect.pBits, rect.Pitch))
|
||||
}
|
||||
|
||||
// copy from GPU memory to system memory
|
||||
unsafe fn ohgodwhat(&mut self, frame: *mut IDXGIResource) -> io::Result<()> {
|
||||
unsafe fn ohgodwhat(&mut self, frame: *mut IDXGIResource) -> io::Result<*mut IDXGISurface> {
|
||||
let mut texture: *mut ID3D11Texture2D = ptr::null_mut();
|
||||
wrap_hresult((*frame).QueryInterface(
|
||||
(*frame).QueryInterface(
|
||||
&IID_ID3D11Texture2D,
|
||||
&mut texture as *mut *mut _ as *mut *mut _,
|
||||
))?;
|
||||
);
|
||||
let texture = ComPtr(texture);
|
||||
|
||||
#[allow(invalid_value)]
|
||||
@@ -372,37 +370,24 @@ impl Capturer {
|
||||
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
texture_desc.MiscFlags = 0;
|
||||
|
||||
// Avoid per-frame staging texture allocation and the kernel allocation churn it causes.
|
||||
let mut current: D3D11_TEXTURE2D_DESC = mem::zeroed();
|
||||
if !self.surface.is_null() {
|
||||
(*self.readable.0).GetDesc(&mut current);
|
||||
}
|
||||
if current.Width != texture_desc.Width
|
||||
|| current.Height != texture_desc.Height
|
||||
|| current.Format != texture_desc.Format
|
||||
{
|
||||
let mut readable = ptr::null_mut();
|
||||
wrap_hresult((*self.device.0).CreateTexture2D(
|
||||
&mut texture_desc,
|
||||
ptr::null(),
|
||||
&mut readable,
|
||||
))?;
|
||||
(*readable).SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM);
|
||||
let readable = ComPtr(readable);
|
||||
let mut readable = ptr::null_mut();
|
||||
wrap_hresult((*self.device.0).CreateTexture2D(
|
||||
&mut texture_desc,
|
||||
ptr::null(),
|
||||
&mut readable,
|
||||
))?;
|
||||
(*readable).SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM);
|
||||
let readable = ComPtr(readable);
|
||||
|
||||
let mut surface = ptr::null_mut();
|
||||
wrap_hresult((*readable.0).QueryInterface(
|
||||
&IID_IDXGISurface,
|
||||
&mut surface as *mut *mut _ as *mut *mut _,
|
||||
))?;
|
||||
let mut surface = ptr::null_mut();
|
||||
(*readable.0).QueryInterface(
|
||||
&IID_IDXGISurface,
|
||||
&mut surface as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
|
||||
self.readable = readable;
|
||||
self.surface = ComPtr(surface);
|
||||
}
|
||||
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
|
||||
|
||||
(*self.context.0).CopyResource(self.readable.0 as *mut _, texture.0 as *mut _);
|
||||
|
||||
Ok(())
|
||||
Ok(surface)
|
||||
}
|
||||
|
||||
pub fn frame<'a>(&'a mut self, timeout: UINT) -> io::Result<Frame<'a>> {
|
||||
@@ -500,10 +485,10 @@ impl Capturer {
|
||||
}
|
||||
|
||||
let mut texture: *mut ID3D11Texture2D = ptr::null_mut();
|
||||
wrap_hresult((*frame.0).QueryInterface(
|
||||
(*frame.0).QueryInterface(
|
||||
&IID_ID3D11Texture2D,
|
||||
&mut texture as *mut *mut _ as *mut *mut _,
|
||||
))?;
|
||||
);
|
||||
let texture = ComPtr(texture);
|
||||
self.texture = texture;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -222,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Android is scoped-storage only: the peer may never touch anything outside the app
|
||||
/// workspace (`Config::get_home()`, i.e. the app-specific external files directory).
|
||||
///
|
||||
/// Every peer supplied path must be validated with this before it reaches the
|
||||
/// filesystem, for reads, writes, renames, creations and deletions alike. The path is
|
||||
/// resolved to its canonical form (of the deepest existing ancestor, so paths that are
|
||||
/// about to be created are handled too) so symlinks cannot escape the workspace.
|
||||
///
|
||||
/// Only the `ReadDir` protocol action treats an empty path as the home directory.
|
||||
/// Callers must opt in to that protocol-specific behavior with `allow_empty`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
// Canonicalize the deepest existing ancestor and re-append the missing tail.
|
||||
fn resolve(path: &Path) -> Option<PathBuf> {
|
||||
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut base = path.to_path_buf();
|
||||
loop {
|
||||
if let Ok(mut resolved) = base.canonicalize() {
|
||||
while let Some(component) = tail.pop() {
|
||||
resolved.push(component);
|
||||
}
|
||||
return Some(resolved);
|
||||
}
|
||||
tail.push(base.file_name()?.to_os_string());
|
||||
if !base.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return allow_empty;
|
||||
}
|
||||
let path = Path::new(path);
|
||||
// `..` is never needed by the protocol and would defeat the prefix check below.
|
||||
if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) {
|
||||
return false;
|
||||
}
|
||||
let home = Config::get_home();
|
||||
let home = home.canonicalize().unwrap_or(home);
|
||||
if home.as_os_str().is_empty() {
|
||||
return false;
|
||||
}
|
||||
// `Path::starts_with` compares whole components, and is true for equal paths.
|
||||
resolve(path).map_or(false, |target| target.starts_with(&home))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_main() -> bool {
|
||||
*IS_MAIN
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2912,6 +2912,7 @@ pub mod server_side {
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
app_dir: JString,
|
||||
home_dir: JString,
|
||||
custom_client_config: JString,
|
||||
) {
|
||||
log::debug!("startServer from jvm");
|
||||
@@ -2919,6 +2920,9 @@ pub mod server_side {
|
||||
if let Ok(app_dir) = env.get_string(&app_dir) {
|
||||
*config::APP_DIR.write().unwrap() = app_dir.into();
|
||||
}
|
||||
if let Ok(home_dir) = env.get_string(&home_dir) {
|
||||
*config::APP_HOME_DIR.write().unwrap() = home_dir.into();
|
||||
}
|
||||
if let Ok(custom_client_config) = env.get_string(&custom_client_config) {
|
||||
if !custom_client_config.is_empty() {
|
||||
let custom_client_config: String = custom_client_config.into();
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "نسخ إلى الحافظة"),
|
||||
("Enable remote printer", "تمكين الطابعة عن بُعد"),
|
||||
("Downloading {}", "جارٍ تنزيل {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Скапіяваць у буфер абмену"),
|
||||
("Enable remote printer", "Выкарыстоўваць аддалены прынтар"),
|
||||
("Downloading {}", "Ідзе спампоўванне {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Копиране в клипборда"),
|
||||
("Enable remote printer", "Позволяване на отдалечен принтер"),
|
||||
("Downloading {}", "Изтегляне на {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copia al porta-retalls"),
|
||||
("Enable remote printer", "Habilita l'impressora remota"),
|
||||
("Downloading {}", "Descarregant {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "复制到剪贴板"),
|
||||
("Enable remote printer", "启用远程打印机"),
|
||||
("Downloading {}", "正在下载 {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopírovat do schránky"),
|
||||
("Enable remote printer", "Povolit vzdálenou tiskárnu"),
|
||||
("Downloading {}", "Stahuje se {}"),
|
||||
@@ -758,5 +761,7 @@ 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í."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopiér til udklipsholder"),
|
||||
("Enable remote printer", "Aktivér fjernprinter"),
|
||||
("Downloading {}", "Downloader {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "In Zwischenablage kopieren"),
|
||||
("Enable remote printer", "Entfernten Drucker aktivieren"),
|
||||
("Downloading {}", "{} herunterladen"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Αντιγραφή στο πρόχειρο"),
|
||||
("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"),
|
||||
("Downloading {}", "Γίνεται Λήψη {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -275,5 +275,6 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopii al la poŝo"),
|
||||
("Enable remote printer", "Ebligi foran presilon"),
|
||||
("Downloading {}", "Elŝutas {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copiar al portapapeles"),
|
||||
("Enable remote printer", "Habilitar impresora remota"),
|
||||
("Downloading {}", "Descargando {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopeeri lõikelauale"),
|
||||
("Enable remote printer", "Luba kaugprinter"),
|
||||
("Downloading {}", "Allalaadimine: {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopiatu arbelera"),
|
||||
("Enable remote printer", "Gaitu urruneko inprimagailua"),
|
||||
("Downloading {}", "{} deskargatzen"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "در کلیپ بورد کپی کنید"),
|
||||
("Enable remote printer", "چاپگر از راه دور را فعال کنید"),
|
||||
("Downloading {}", "بارگیری {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "متن یا تصاویری که در یک نشست راه دور کپی میشوند به کلیپبورد سایر نشستهای متصل شما نیز ارسال میشوند."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopioi leikepöydälle"),
|
||||
("Enable remote printer", "Ota etätulostin käyttöön"),
|
||||
("Downloading {}", "Ladataan {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copier dans le presse-papier"),
|
||||
("Enable remote printer", "Activer l’impression à distance"),
|
||||
("Downloading {}", "Téléchargement de {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "ბუფერში კოპირება"),
|
||||
("Enable remote printer", "დისტანციური პრინტერის ჩართვა"),
|
||||
("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"),
|
||||
@@ -758,5 +761,7 @@ 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", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"),
|
||||
("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"),
|
||||
("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"),
|
||||
@@ -758,5 +761,7 @@ 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", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "העתק ללוח"),
|
||||
("Enable remote printer", "אפשר מדפסת מרוחקת"),
|
||||
("Downloading {}", "מוריד את {}"),
|
||||
@@ -758,5 +761,7 @@ 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", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"),
|
||||
("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"),
|
||||
("Downloading {}", "{} डाउनलोड हो रहा है"),
|
||||
@@ -758,5 +761,7 @@ 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", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopiraj u međuspremnik"),
|
||||
("Enable remote printer", "Omogući udaljeni pisač"),
|
||||
("Downloading {}", "Preuzimanje {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Másolás a vágólapra"),
|
||||
("Enable remote printer", "Távoli nyomtatók engedélyezése"),
|
||||
("Downloading {}", "{} letöltése"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Salin ke papan klip"),
|
||||
("Enable remote printer", "Aktifkan printer jarak jauh"),
|
||||
("Downloading {}", "Mendownload {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copia negli appunti"),
|
||||
("Enable remote printer", "Abilita stampante remota"),
|
||||
("Downloading {}", "Download {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "クリップボードにコピー"),
|
||||
("Enable remote printer", "リモートプリンターを有効化する"),
|
||||
("Downloading {}", "{} をダウンロード中"),
|
||||
@@ -758,5 +761,7 @@ 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つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "클립보드에 복사"),
|
||||
("Enable remote printer", "원격 프린터 허용"),
|
||||
("Downloading {}", "{} 다운로드 중"),
|
||||
@@ -758,5 +761,7 @@ 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", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Көшіру-тақтаға көшіру"),
|
||||
("Enable remote printer", "Қашықтағы принтерді іске қосу"),
|
||||
("Downloading {}", "{} жүктелуде"),
|
||||
@@ -758,5 +761,7 @@ 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", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopijuoti į iškarpinę"),
|
||||
("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"),
|
||||
("Downloading {}", "Atsisiunčiama {}"),
|
||||
@@ -758,5 +761,7 @@ 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ę."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopēt starpliktuvē"),
|
||||
("Enable remote printer", "Iespējot attālo printeri"),
|
||||
("Downloading {}", "Notiek {} lejupielāde"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"),
|
||||
("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"),
|
||||
("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"),
|
||||
@@ -758,5 +761,7 @@ 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", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopier til utklipstavlen"),
|
||||
("Enable remote printer", "Aktiver fjernskriver"),
|
||||
("Downloading {}", "Laster ned {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", "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 {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopiuj do schowka"),
|
||||
("Enable remote printer", "Włącz zdalne drukowanie"),
|
||||
("Downloading {}", "Pobieranie {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copiar para a área de transferência"),
|
||||
("Enable remote printer", "Ativar impressora remota"),
|
||||
("Downloading {}", "A transferir {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copiar para área de transferência"),
|
||||
("Enable remote printer", "Habilitar impressora remota"),
|
||||
("Downloading {}", "Baixando {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Copiază în clipboard"),
|
||||
("Enable remote printer", "Activează imprimanta la distanță"),
|
||||
("Downloading {}", "Se descarcă {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Копировать в буфер обмена"),
|
||||
("Enable remote printer", "Использовать удалённый принтер"),
|
||||
("Downloading {}", "Скачивание"),
|
||||
@@ -758,5 +761,7 @@ 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", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Còpia in punta de billete"),
|
||||
("Enable remote printer", "Abìlita imprentadora remota"),
|
||||
("Downloading {}", "Iscarrighende {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopírovať do schránky"),
|
||||
("Enable remote printer", "Povoliť vzdialenú tlačiareň"),
|
||||
("Downloading {}", "Sťahuje sa {}"),
|
||||
@@ -758,5 +761,7 @@ 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í."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +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", ""),
|
||||
("Copy to clipboard", "Kopiraj v odložišče"),
|
||||
("Enable remote printer", "Omogoči oddaljeni tiskalnik"),
|
||||
("Downloading {}", "Prenašanje {}"),
|
||||
@@ -758,5 +761,7 @@ 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."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."),
|
||||
("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."),
|
||||
("Save as", "Ruaj si"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopjo te clipboard"),
|
||||
("Enable remote printer", "Aktivizo printerin në distancë"),
|
||||
("Downloading {}", "Duke shkarkuar {}"),
|
||||
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Vazhdo"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Shfletuesi nuk u hap? Përdorni URL-në më poshtë për të hyrë."),
|
||||
("Lock canvas", "Kyç canvas"),
|
||||
("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."),
|
||||
("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."),
|
||||
("Save as", "Sačuvaj kao"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopiraj u clipboard"),
|
||||
("Enable remote printer", "Omogući udaljeni štampač"),
|
||||
("Downloading {}", "Preuzimanje {}"),
|
||||
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Nastavi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Pregledač se nije otvorio? Za prijavu koristite URL ispod."),
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."),
|
||||
].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