mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 06:21:02 +03:00
Compare commits
17 Commits
dxgi-reuse
...
hdr-tonema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce968c02dd | ||
|
|
89d444b950 | ||
|
|
792618e59a | ||
|
|
2f28a96f6a | ||
|
|
2f3a6789de | ||
|
|
6e502cde2e | ||
|
|
3730ab0733 | ||
|
|
696451b08b | ||
|
|
a7df138fc7 | ||
|
|
f28ac38ccf | ||
|
|
28cf1836e6 | ||
|
|
1ec1b9e7e3 | ||
|
|
2c84c8fb13 | ||
|
|
66ab0b87f6 | ||
|
|
169f74f8d9 | ||
|
|
d4b06a6c5c | ||
|
|
03a7fc5992 |
4
.github/workflows/flutter-build.yml
vendored
4
.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 }}"
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ serde = {version="1.0", features=["derive"]}
|
||||
[dependencies.winapi]
|
||||
version = "0.3"
|
||||
default-features = true
|
||||
features = ["dxgi", "dxgi1_2", "dxgi1_5", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
|
||||
features = ["dxgi", "dxgi1_2", "dxgi1_5", "dxgi1_6", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block = "0.1"
|
||||
|
||||
629
libs/scrap/src/dxgi/hdr.rs
Normal file
629
libs/scrap/src/dxgi/hdr.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
//! HDR desktop -> SDR normalization for Desktop Duplication frames.
|
||||
//!
|
||||
//! With HDR enabled Windows composes the desktop as linear scRGB in
|
||||
//! R16G16B16A16_FLOAT, and SDR "white" sits at the user's SDR content
|
||||
//! brightness (DISPLAYCONFIG_SDR_WHITE_LEVEL) rather than at 1.0. The legacy
|
||||
//! DuplicateOutput converts that to BGRA8 by clipping, which is the washed-out
|
||||
//! picture reported for HDR hosts. This pass divides by the SDR white level,
|
||||
//! clamps, and applies the sRGB transfer, so SDR content comes out exactly as
|
||||
//! it would from an SDR desktop.
|
||||
//!
|
||||
//! It is a normalization, not a tone map: anything brighter than SDR white
|
||||
//! (HDR video, HDR games) clips to white on the SDR viewer, where the local
|
||||
//! HDR display would show it brighter than white. A roll-off would have to
|
||||
//! move SDR white below 1.0 to make headroom, trading the accuracy of the SDR
|
||||
//! content this pass exists for, so it is deliberately not done.
|
||||
//!
|
||||
//! Windows 11 22H2 also composes Advanced Color SDR (WCG) desktops in FP16,
|
||||
//! but there 1.0 is the display's reference white rather than 80 nits and no
|
||||
//! SDR white level applies. IDXGIOutput6 tells the two apart, and for a
|
||||
//! non-HDR output the pass only applies the scRGB -> sRGB transfer.
|
||||
//!
|
||||
//! The conversion is automatic and stays on the controlled side on purpose:
|
||||
//! the controller renders through Flutter external textures, which are 8-bit
|
||||
//! on every desktop platform, so there is nothing to gain from sending HDR.
|
||||
//! Real HDR pass-through, if the renderer ever supports it, should follow the
|
||||
//! Sunshine/Moonlight pattern instead: an `hdr` capability bit advertised by
|
||||
//! the controller behind an explicit user toggle, negotiated like i444.
|
||||
|
||||
use super::ComPtr;
|
||||
use hbb_common::log;
|
||||
use std::{
|
||||
io, mem, ptr,
|
||||
sync::{atomic::AtomicBool, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::{
|
||||
basetsd::SIZE_T,
|
||||
dxgi::{CreateDXGIFactory1, IDXGIFactory1, IID_IDXGIFactory1, DXGI_OUTPUT_DESC},
|
||||
dxgi1_2::IDXGIOutput1,
|
||||
dxgi1_6::{IDXGIOutput6, IID_IDXGIOutput6, DXGI_OUTPUT_DESC1},
|
||||
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
dxgitype::{DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_SAMPLE_DESC},
|
||||
minwindef::{FALSE, LPCVOID, UINT, ULONG},
|
||||
ntdef::{LONG, LPCSTR, WCHAR},
|
||||
winerror::S_OK,
|
||||
},
|
||||
um::{
|
||||
d3d11::*,
|
||||
d3dcommon::{ID3DBlob, ID3DInclude, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SHADER_MACRO},
|
||||
libloaderapi::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
|
||||
unknwnbase::IUnknown,
|
||||
wingdi::{
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
|
||||
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
||||
DISPLAYCONFIG_TOPOLOGY_ID,
|
||||
},
|
||||
winnt::HRESULT,
|
||||
},
|
||||
};
|
||||
|
||||
/// Set once the tone-map can never work in this process (no d3dcompiler, the
|
||||
/// shaders do not compile). Capturers then stop asking DXGI for float frames.
|
||||
/// Device-specific failures are not recorded here; the capturer that hit one
|
||||
/// re-duplicates without the tone-map on its own.
|
||||
pub static UNAVAILABLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Failures no capturer on this machine can recover from, as opposed to
|
||||
/// device-specific ones that a recreated capturer may not hit again.
|
||||
pub fn is_permanent(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::Unsupported
|
||||
}
|
||||
|
||||
const VS_SRC: &str = "\
|
||||
float4 main(uint id : SV_VertexID) : SV_Position {
|
||||
float2 uv = float2((id << 1) & 2, id & 2);
|
||||
return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
|
||||
}";
|
||||
|
||||
const PS_SRC: &str = "\
|
||||
Texture2D<float4> src : register(t0);
|
||||
cbuffer Params : register(b0) { float inv_sdr_white; float3 pad; };
|
||||
float4 main(float4 pos : SV_Position) : SV_Target {
|
||||
float3 lin = saturate(src.Load(int3(pos.xy, 0)).rgb * inv_sdr_white);
|
||||
float3 lo = lin * 12.92;
|
||||
float3 hi = 1.055 * pow(lin, 1.0 / 2.4) - 0.055;
|
||||
return float4(lerp(hi, lo, step(lin, 0.0031308)), 1.0);
|
||||
}";
|
||||
|
||||
const OUTPUT_STATE_REFRESH: Duration = Duration::from_secs(1);
|
||||
|
||||
pub struct HdrToSdr {
|
||||
device: ComPtr<ID3D11Device>,
|
||||
context: ComPtr<ID3D11DeviceContext>,
|
||||
vs: ComPtr<ID3D11VertexShader>,
|
||||
ps: ComPtr<ID3D11PixelShader>,
|
||||
params: ComPtr<ID3D11Buffer>,
|
||||
target: ComPtr<ID3D11Texture2D>,
|
||||
rtv: ComPtr<ID3D11RenderTargetView>,
|
||||
srv: ComPtr<ID3D11ShaderResourceView>,
|
||||
// Texture `srv` was created for. The view keeps it alive, so the address
|
||||
// cannot be recycled behind our back.
|
||||
srv_source: *mut ID3D11Texture2D,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_name: [WCHAR; 32],
|
||||
// Advanced Color state is read from `output6`, which is re-enumerated from a
|
||||
// fresh factory whenever `factory` stops being current.
|
||||
factory: ComPtr<IDXGIFactory1>,
|
||||
output6: ComPtr<IDXGIOutput6>,
|
||||
is_hdr: bool,
|
||||
// DISPLAYCONFIG units (1000 == 80 nits == scRGB 1.0). `None` when it could
|
||||
// not be read, in which case 80 nits is assumed until it can.
|
||||
sdr_white_level: Option<u32>,
|
||||
queried_at: Instant,
|
||||
}
|
||||
|
||||
impl HdrToSdr {
|
||||
pub fn new(
|
||||
device: *mut ID3D11Device,
|
||||
context: *mut ID3D11DeviceContext,
|
||||
output: *mut IDXGIOutput1,
|
||||
device_name: &[WCHAR; 32],
|
||||
) -> io::Result<Self> {
|
||||
unsafe {
|
||||
if device.is_null() || context.is_null() {
|
||||
return Err(other("no d3d11 device"));
|
||||
}
|
||||
(*device).AddRef();
|
||||
let device = ComPtr(device);
|
||||
(*context).AddRef();
|
||||
let context = ComPtr(context);
|
||||
|
||||
let compile = load_d3d_compile()?;
|
||||
let vs_code = compile_shader(compile, VS_SRC, b"vs_4_0\0")?;
|
||||
let ps_code = compile_shader(compile, PS_SRC, b"ps_4_0\0")?;
|
||||
let mut vs = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreateVertexShader(
|
||||
(*vs_code.0).GetBufferPointer(),
|
||||
(*vs_code.0).GetBufferSize(),
|
||||
ptr::null_mut(),
|
||||
&mut vs,
|
||||
),
|
||||
"CreateVertexShader",
|
||||
)?;
|
||||
let vs = ComPtr(vs);
|
||||
let mut ps = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreatePixelShader(
|
||||
(*ps_code.0).GetBufferPointer(),
|
||||
(*ps_code.0).GetBufferSize(),
|
||||
ptr::null_mut(),
|
||||
&mut ps,
|
||||
),
|
||||
"CreatePixelShader",
|
||||
)?;
|
||||
let ps = ComPtr(ps);
|
||||
|
||||
// Not found leaves the factory null, so the first refresh enumerates
|
||||
// again instead of trusting the capturer's possibly stale output.
|
||||
let (factory, mut output6) = enumerate_output6(device_name);
|
||||
if output6.is_null() {
|
||||
output6 = query_output6(output as *mut IUnknown);
|
||||
}
|
||||
// Float frames are only requested where IDXGIOutput6 exists, so an
|
||||
// unreadable description still comes from an HDR-capable stack.
|
||||
let is_hdr = output_is_hdr(output6.0).unwrap_or(true);
|
||||
let sdr_white_level = if is_hdr {
|
||||
query_sdr_white_level(device_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if is_hdr && sdr_white_level.is_none() {
|
||||
log::warn!(
|
||||
"HDR output but the SDR white level cannot be read (needs Windows 10 1709+), \
|
||||
assuming 80 nits until it can"
|
||||
);
|
||||
}
|
||||
let init = params_data(sdr_white_level);
|
||||
let desc = D3D11_BUFFER_DESC {
|
||||
ByteWidth: mem::size_of_val(&init) as _,
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
StructureByteStride: 0,
|
||||
};
|
||||
let data = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: init.as_ptr() as _,
|
||||
SysMemPitch: 0,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut params = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreateBuffer(&desc, &data, &mut params),
|
||||
"CreateBuffer",
|
||||
)?;
|
||||
let params = ComPtr(params);
|
||||
log::info!(
|
||||
"scRGB desktop conversion ready, hdr {is_hdr}, sdr white level {sdr_white_level:?}"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
vs,
|
||||
ps,
|
||||
params,
|
||||
target: ComPtr(ptr::null_mut()),
|
||||
rtv: ComPtr(ptr::null_mut()),
|
||||
srv: ComPtr(ptr::null_mut()),
|
||||
srv_source: ptr::null_mut(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
device_name: *device_name,
|
||||
factory,
|
||||
output6,
|
||||
is_hdr,
|
||||
sdr_white_level,
|
||||
queried_at: Instant::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `source` (R16G16B16A16_FLOAT) into an owned B8G8R8A8_UNORM
|
||||
/// texture of the same size and returns it. The texture stays valid until
|
||||
/// the next call.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
source: *mut ID3D11Texture2D,
|
||||
desc: &D3D11_TEXTURE2D_DESC,
|
||||
) -> io::Result<*mut ID3D11Texture2D> {
|
||||
unsafe {
|
||||
self.refresh_output_state();
|
||||
self.ensure_target(desc.Width, desc.Height)?;
|
||||
self.ensure_source_view(source)?;
|
||||
|
||||
let ctx = self.context.0;
|
||||
let rtv = self.rtv.0;
|
||||
let srv = self.srv.0;
|
||||
let params = self.params.0;
|
||||
let viewport = D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: self.width as f32,
|
||||
Height: self.height as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
(*ctx).OMSetRenderTargets(1, &rtv, ptr::null_mut());
|
||||
(*ctx).OMSetBlendState(ptr::null_mut(), &[0.0; 4], 0xffff_ffff);
|
||||
(*ctx).OMSetDepthStencilState(ptr::null_mut(), 0);
|
||||
(*ctx).RSSetState(ptr::null_mut());
|
||||
(*ctx).RSSetViewports(1, &viewport);
|
||||
(*ctx).IASetInputLayout(ptr::null_mut());
|
||||
(*ctx).IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
(*ctx).VSSetShader(self.vs.0, ptr::null(), 0);
|
||||
(*ctx).PSSetShader(self.ps.0, ptr::null(), 0);
|
||||
(*ctx).PSSetConstantBuffers(0, 1, ¶ms);
|
||||
(*ctx).PSSetShaderResources(0, 1, &srv);
|
||||
(*ctx).Draw(3, 0);
|
||||
// Unbind so the next frame's copy and the encoder never see the
|
||||
// target as a live render target or the desktop image as a bound
|
||||
// shader input.
|
||||
let no_srv: *mut ID3D11ShaderResourceView = ptr::null_mut();
|
||||
(*ctx).PSSetShaderResources(0, 1, &no_srv);
|
||||
(*ctx).OMSetRenderTargets(0, ptr::null(), ptr::null_mut());
|
||||
Ok(self.target.0)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn ensure_target(&mut self, width: u32, height: u32) -> io::Result<()> {
|
||||
if !self.target.is_null() && self.width == width && self.height == height {
|
||||
return Ok(());
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED,
|
||||
};
|
||||
let mut target = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut target),
|
||||
"CreateTexture2D",
|
||||
)?;
|
||||
let target = ComPtr(target);
|
||||
let mut rtv = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateRenderTargetView(target.0 as *mut _, ptr::null(), &mut rtv),
|
||||
"CreateRenderTargetView",
|
||||
)?;
|
||||
self.rtv = ComPtr(rtv);
|
||||
self.target = target;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn ensure_source_view(&mut self, source: *mut ID3D11Texture2D) -> io::Result<()> {
|
||||
if !self.srv.is_null() && self.srv_source == source {
|
||||
return Ok(());
|
||||
}
|
||||
let mut srv = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateShaderResourceView(source as *mut _, ptr::null(), &mut srv),
|
||||
"CreateShaderResourceView",
|
||||
)?;
|
||||
self.srv = ComPtr(srv);
|
||||
self.srv_source = source;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Advanced Color state is dynamic: HDR can be switched on or off, or a WCG
|
||||
// desktop can turn into an HDR one, without the duplication being lost. An
|
||||
// output's description is a snapshot, so once the factory is no longer
|
||||
// current a new factory and output are needed to see the new state, as the
|
||||
// GetDesc1 docs require.
|
||||
unsafe fn refresh_output_state(&mut self) {
|
||||
if self.queried_at.elapsed() < OUTPUT_STATE_REFRESH {
|
||||
return;
|
||||
}
|
||||
self.queried_at = Instant::now();
|
||||
if self.factory.is_null() || (*self.factory.0).IsCurrent() == FALSE {
|
||||
let (factory, output6) = enumerate_output6(&self.device_name);
|
||||
if !output6.is_null() {
|
||||
self.factory = factory;
|
||||
self.output6 = output6;
|
||||
} else {
|
||||
// Keep reading the old output, but enumerate again next time.
|
||||
self.factory = ComPtr(ptr::null_mut());
|
||||
}
|
||||
}
|
||||
let is_hdr = output_is_hdr(self.output6.0).unwrap_or(self.is_hdr);
|
||||
let level = if is_hdr {
|
||||
query_sdr_white_level(&self.device_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A transiently unreadable level keeps the last known one.
|
||||
if is_hdr == self.is_hdr && (level.is_none() || level == self.sdr_white_level) {
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"output changed: hdr {} -> {is_hdr}, sdr white level {:?} -> {level:?}",
|
||||
self.is_hdr,
|
||||
self.sdr_white_level
|
||||
);
|
||||
if is_hdr && level.is_none() {
|
||||
log::warn!(
|
||||
"HDR output but the SDR white level cannot be read, assuming 80 nits until it can"
|
||||
);
|
||||
}
|
||||
self.is_hdr = is_hdr;
|
||||
self.sdr_white_level = level;
|
||||
let data = params_data(level);
|
||||
(*self.context.0).UpdateSubresource(
|
||||
self.params.0 as *mut _,
|
||||
0,
|
||||
ptr::null(),
|
||||
data.as_ptr() as _,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `None` is either a non-HDR (WCG) output, where 1.0 already is the display's
|
||||
// reference white, or an HDR output whose level is unknown; both use 1.0.
|
||||
fn params_data(sdr_white_level: Option<u32>) -> [f32; 4] {
|
||||
[
|
||||
1000.0 / sdr_white_level.unwrap_or(1000) as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
// FP16 desktop composition means either HDR (scene-referred, 1.0 == 80 nits)
|
||||
// or, since Windows 11 22H2, Advanced Color SDR (display-referred), and only
|
||||
// IDXGIOutput6 (Windows 10 1703) tells them apart. `None` when it cannot be
|
||||
// read right now.
|
||||
unsafe fn output_is_hdr(output6: *mut IDXGIOutput6) -> Option<bool> {
|
||||
if output6.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut desc: DXGI_OUTPUT_DESC1 = mem::zeroed();
|
||||
if (*output6).GetDesc1(&mut desc) != S_OK {
|
||||
return None;
|
||||
}
|
||||
Some(desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
|
||||
}
|
||||
|
||||
unsafe fn query_output6(object: *mut IUnknown) -> ComPtr<IDXGIOutput6> {
|
||||
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
|
||||
if !object.is_null() {
|
||||
(*object).QueryInterface(
|
||||
&IID_IDXGIOutput6,
|
||||
&mut output6 as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
}
|
||||
ComPtr(output6)
|
||||
}
|
||||
|
||||
// A fresh factory sees the current display configuration; the output is found
|
||||
// by its GDI name because the outputs of a stale factory keep stale descriptions.
|
||||
// Returns both or neither: a non-null factory guarantees the output came from
|
||||
// its topology, so a caller that sees a null factory knows to enumerate again.
|
||||
unsafe fn enumerate_output6(
|
||||
device_name: &[WCHAR; 32],
|
||||
) -> (ComPtr<IDXGIFactory1>, ComPtr<IDXGIOutput6>) {
|
||||
let mut factory: *mut c_void = ptr::null_mut();
|
||||
if CreateDXGIFactory1(&IID_IDXGIFactory1, &mut factory) != S_OK {
|
||||
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
|
||||
}
|
||||
let factory = ComPtr(factory as *mut IDXGIFactory1);
|
||||
let mut adapter_index = 0;
|
||||
loop {
|
||||
let mut adapter = ptr::null_mut();
|
||||
if (*factory.0).EnumAdapters1(adapter_index, &mut adapter) != S_OK {
|
||||
break;
|
||||
}
|
||||
let adapter = ComPtr(adapter);
|
||||
adapter_index += 1;
|
||||
let mut output_index = 0;
|
||||
loop {
|
||||
let mut output = ptr::null_mut();
|
||||
if (*adapter.0).EnumOutputs(output_index, &mut output) != S_OK {
|
||||
break;
|
||||
}
|
||||
let output = ComPtr(output);
|
||||
output_index += 1;
|
||||
let mut desc: DXGI_OUTPUT_DESC = mem::zeroed();
|
||||
if (*output.0).GetDesc(&mut desc) == S_OK && wide_eq(&desc.DeviceName, device_name) {
|
||||
let output6 = query_output6(output.0 as *mut IUnknown);
|
||||
if output6.is_null() {
|
||||
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
|
||||
}
|
||||
return (factory, output6);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()))
|
||||
}
|
||||
|
||||
fn other(msg: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::Other, msg.into())
|
||||
}
|
||||
|
||||
fn check(hr: HRESULT, what: &str) -> io::Result<()> {
|
||||
if hr == S_OK {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(other(format!("{what} failed: {hr:#x}")))
|
||||
}
|
||||
}
|
||||
|
||||
// D3DCompile(pSrcData, SrcDataSize, pSourceName, pDefines, pInclude,
|
||||
// pEntrypoint, pTarget, Flags1, Flags2, ppCode, ppErrorMsgs)
|
||||
type D3DCompileFn = unsafe extern "system" fn(
|
||||
LPCVOID,
|
||||
SIZE_T,
|
||||
LPCSTR,
|
||||
*const D3D_SHADER_MACRO,
|
||||
*mut ID3DInclude,
|
||||
LPCSTR,
|
||||
LPCSTR,
|
||||
UINT,
|
||||
UINT,
|
||||
*mut *mut ID3DBlob,
|
||||
*mut *mut ID3DBlob,
|
||||
) -> HRESULT;
|
||||
|
||||
static D3D_COMPILE: OnceLock<Result<D3DCompileFn, String>> = OnceLock::new();
|
||||
|
||||
// Loaded once per process and kept: the compiler DLL is only needed on HDR
|
||||
// desktops, and an import-time link would make every install depend on it.
|
||||
fn load_d3d_compile() -> io::Result<D3DCompileFn> {
|
||||
D3D_COMPILE
|
||||
.get_or_init(|| unsafe { find_d3d_compile() })
|
||||
.clone()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))
|
||||
}
|
||||
|
||||
unsafe fn find_d3d_compile() -> Result<D3DCompileFn, String> {
|
||||
let name: Vec<u16> = "d3dcompiler_47.dll\0".encode_utf16().collect();
|
||||
let module = LoadLibraryExW(name.as_ptr(), ptr::null_mut(), LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
if module.is_null() {
|
||||
return Err("d3dcompiler_47.dll not available".into());
|
||||
}
|
||||
let f = GetProcAddress(module, b"D3DCompile\0".as_ptr() as _);
|
||||
if f.is_null() {
|
||||
return Err("D3DCompile not exported".into());
|
||||
}
|
||||
Ok(mem::transmute::<_, D3DCompileFn>(f))
|
||||
}
|
||||
|
||||
unsafe fn compile_shader(
|
||||
compile: D3DCompileFn,
|
||||
src: &str,
|
||||
target: &[u8],
|
||||
) -> io::Result<ComPtr<ID3DBlob>> {
|
||||
let mut code = ptr::null_mut();
|
||||
let mut errors = ptr::null_mut();
|
||||
let hr = compile(
|
||||
src.as_ptr() as _,
|
||||
src.len(),
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
ptr::null_mut(),
|
||||
b"main\0".as_ptr() as _,
|
||||
target.as_ptr() as _,
|
||||
0,
|
||||
0,
|
||||
&mut code,
|
||||
&mut errors,
|
||||
);
|
||||
let errors = ComPtr(errors);
|
||||
if hr != S_OK || code.is_null() {
|
||||
let msg = if errors.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
(*errors.0).GetBufferPointer() as *const u8,
|
||||
(*errors.0).GetBufferSize(),
|
||||
);
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
};
|
||||
if !code.is_null() {
|
||||
(*(code as *mut IUnknown)).Release();
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!("D3DCompile failed: {hr:#x} {msg}"),
|
||||
));
|
||||
}
|
||||
Ok(ComPtr(code))
|
||||
}
|
||||
|
||||
const DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL: u32 = 11;
|
||||
const QDC_ONLY_ACTIVE_PATHS: u32 = 2;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct DISPLAYCONFIG_SDR_WHITE_LEVEL {
|
||||
header: DISPLAYCONFIG_DEVICE_INFO_HEADER,
|
||||
SDRWhiteLevel: ULONG,
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn GetDisplayConfigBufferSizes(
|
||||
flags: u32,
|
||||
numPathArrayElements: *mut u32,
|
||||
numModeInfoArrayElements: *mut u32,
|
||||
) -> LONG;
|
||||
fn QueryDisplayConfig(
|
||||
flags: u32,
|
||||
numPathArrayElements: *mut u32,
|
||||
pathArray: *mut DISPLAYCONFIG_PATH_INFO,
|
||||
numModeInfoArrayElements: *mut u32,
|
||||
modeInfoArray: *mut DISPLAYCONFIG_MODE_INFO,
|
||||
currentTopologyId: *mut DISPLAYCONFIG_TOPOLOGY_ID,
|
||||
) -> LONG;
|
||||
fn DisplayConfigGetDeviceInfo(requestPacket: *mut DISPLAYCONFIG_DEVICE_INFO_HEADER) -> LONG;
|
||||
}
|
||||
|
||||
/// SDR white level of the output whose GDI name is `device_name`
|
||||
/// (e.g. `\\.\DISPLAY1`), in DISPLAYCONFIG units (1000 == 80 nits). `None`
|
||||
/// when the query fails (before Windows 10 1709) or reports 0.
|
||||
fn query_sdr_white_level(device_name: &[WCHAR; 32]) -> Option<u32> {
|
||||
unsafe {
|
||||
let mut n_paths = 0u32;
|
||||
let mut n_modes = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut n_paths, &mut n_modes) != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Vec<DISPLAYCONFIG_PATH_INFO> = vec![mem::zeroed(); n_paths as usize];
|
||||
let mut modes: Vec<DISPLAYCONFIG_MODE_INFO> = vec![mem::zeroed(); n_modes as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut n_paths,
|
||||
paths.as_mut_ptr(),
|
||||
&mut n_modes,
|
||||
modes.as_mut_ptr(),
|
||||
ptr::null_mut(),
|
||||
) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for path in &paths[..n_paths as usize] {
|
||||
let mut source: DISPLAYCONFIG_SOURCE_DEVICE_NAME = mem::zeroed();
|
||||
source.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
source.header.size = mem::size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as _;
|
||||
source.header.adapterId = path.sourceInfo.adapterId;
|
||||
source.header.id = path.sourceInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut source.header) != 0
|
||||
|| !wide_eq(&source.viewGdiDeviceName, device_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut white: DISPLAYCONFIG_SDR_WHITE_LEVEL = mem::zeroed();
|
||||
white.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL;
|
||||
white.header.size = mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>() as _;
|
||||
white.header.adapterId = path.targetInfo.adapterId;
|
||||
white.header.id = path.targetInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut white.header) == 0 && white.SDRWhiteLevel != 0 {
|
||||
return Some(white.SDRWhiteLevel);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_eq(a: &[WCHAR], b: &[WCHAR]) -> bool {
|
||||
let end = |s: &[WCHAR]| s.iter().position(|&c| c == 0).unwrap_or(s.len());
|
||||
a[..end(a)] == b[..end(b)]
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
use std::{io, mem, ptr, slice};
|
||||
pub mod gdi;
|
||||
pub use gdi::CapturerGDI;
|
||||
pub mod hdr;
|
||||
pub mod mag;
|
||||
|
||||
use winapi::{
|
||||
shared::{
|
||||
dxgi::*,
|
||||
dxgi1_2::*,
|
||||
dxgi1_6::*,
|
||||
dxgiformat::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT},
|
||||
dxgitype::*,
|
||||
minwindef::{DWORD, FALSE, TRUE, UINT},
|
||||
ntdef::LONG,
|
||||
windef::{HMONITOR, RECT},
|
||||
winerror::*,
|
||||
// dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE},
|
||||
},
|
||||
um::{
|
||||
d3d11::*, d3dcommon::D3D_DRIVER_TYPE_UNKNOWN, unknwnbase::IUnknown, wingdi::*,
|
||||
@@ -58,6 +60,7 @@ pub struct Capturer {
|
||||
output_texture: bool,
|
||||
adapter_desc1: DXGI_ADAPTER_DESC1,
|
||||
rotate: Rotate,
|
||||
hdr: Option<hdr::HdrToSdr>,
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
@@ -105,7 +108,7 @@ impl Capturer {
|
||||
}
|
||||
} else {
|
||||
res = wrap_hresult(unsafe {
|
||||
let hres = (*display.inner.0).DuplicateOutput(device.0 as *mut _, &mut duplication);
|
||||
let hres = Self::duplicate_output(&display, device.0, &mut duplication);
|
||||
if hres != S_OK {
|
||||
gdi_capturer = display.create_gdi();
|
||||
println!("Fallback to GDI");
|
||||
@@ -161,7 +164,8 @@ impl Capturer {
|
||||
device,
|
||||
context,
|
||||
duplication: ComPtr(duplication),
|
||||
fastlane: desc.DesktopImageInSystemMemory == TRUE,
|
||||
fastlane: desc.DesktopImageInSystemMemory == TRUE
|
||||
&& desc.ModeDesc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
surface: ComPtr(ptr::null_mut()),
|
||||
texture: ComPtr(ptr::null_mut()),
|
||||
width: display.width() as usize,
|
||||
@@ -174,9 +178,102 @@ impl Capturer {
|
||||
output_texture: false,
|
||||
adapter_desc1,
|
||||
rotate,
|
||||
hdr: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Asks for the float desktop that HDR mode composes so it can be tone-mapped;
|
||||
// the legacy call would hand back DXGI's clipped BGRA8 conversion instead.
|
||||
// Only where IDXGIOutput6 (Windows 10 1703) exists, since that is what later
|
||||
// tells an HDR desktop from a WCG one; Microsoft's duplication sample gates
|
||||
// the float request the same way.
|
||||
unsafe fn duplicate_output(
|
||||
display: &Display,
|
||||
device: *mut ID3D11Device,
|
||||
duplication: &mut *mut IDXGIOutputDuplication,
|
||||
) -> HRESULT {
|
||||
if !hdr::UNAVAILABLE.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
|
||||
(*display.inner.0).QueryInterface(
|
||||
&IID_IDXGIOutput6,
|
||||
&mut output6 as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
if !output6.is_null() {
|
||||
let output6 = ComPtr(output6);
|
||||
let formats = [DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM];
|
||||
let hres = (*output6.0).DuplicateOutput1(
|
||||
device as *mut _,
|
||||
0,
|
||||
formats.len() as UINT,
|
||||
formats.as_ptr(),
|
||||
duplication,
|
||||
);
|
||||
if hres == S_OK {
|
||||
return hres;
|
||||
}
|
||||
hbb_common::log::warn!(
|
||||
"HDR DuplicateOutput1 failed: hr={:#x}, fallback=DuplicateOutput",
|
||||
hres as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
(*display.inner.0).DuplicateOutput(device as *mut _, duplication)
|
||||
}
|
||||
|
||||
unsafe fn tonemap(
|
||||
&mut self,
|
||||
source: *mut ID3D11Texture2D,
|
||||
desc: &D3D11_TEXTURE2D_DESC,
|
||||
) -> io::Result<*mut ID3D11Texture2D> {
|
||||
if self.hdr.is_none() {
|
||||
match hdr::HdrToSdr::new(
|
||||
self.device.0,
|
||||
self.context.0,
|
||||
self.display.inner.0,
|
||||
&self.display.desc.DeviceName,
|
||||
) {
|
||||
Ok(hdr) => self.hdr = Some(hdr),
|
||||
Err(err) => return self.abandon_tonemap(err),
|
||||
}
|
||||
}
|
||||
let converted = match self.hdr.as_mut() {
|
||||
Some(hdr) => hdr.convert(source, desc),
|
||||
None => Err(io::Error::new(io::ErrorKind::Other, "no tone-map")),
|
||||
};
|
||||
match converted {
|
||||
Ok(texture) => Ok(texture),
|
||||
Err(err) => self.abandon_tonemap(err),
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the tone-map and re-duplicates the output the legacy way, so DXGI
|
||||
// hands over clipped BGRA8 (the pre-HDR behaviour). If re-duplication fails,
|
||||
// switch to GDI before returning. The caller sees WouldBlock and asks again.
|
||||
unsafe fn abandon_tonemap<T>(&mut self, err: io::Error) -> io::Result<T> {
|
||||
if hdr::is_permanent(&err) {
|
||||
hdr::UNAVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
hbb_common::log::error!("HDR tone-map failed, re-duplicating without it: {err}");
|
||||
self.hdr = None;
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
self.duplication = ComPtr(ptr::null_mut());
|
||||
let mut duplication = ptr::null_mut();
|
||||
let result = wrap_hresult(
|
||||
(*self.display.inner.0).DuplicateOutput(self.device.0 as *mut _, &mut duplication),
|
||||
);
|
||||
if let Err(err) = result {
|
||||
if self.set_gdi() {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
self.duplication = ComPtr(duplication);
|
||||
let mut desc: DXGI_OUTDUPL_DESC = mem::zeroed();
|
||||
(*duplication).GetDesc(&mut desc);
|
||||
self.fastlane = desc.DesktopImageInSystemMemory == TRUE;
|
||||
Err(io::ErrorKind::WouldBlock.into())
|
||||
}
|
||||
|
||||
fn create_rotations(
|
||||
device: *mut ID3D11Device,
|
||||
context: *mut ID3D11DeviceContext,
|
||||
@@ -330,6 +427,9 @@ impl Capturer {
|
||||
}
|
||||
|
||||
unsafe fn load_frame(&mut self, timeout: UINT) -> io::Result<(*const u8, i32)> {
|
||||
if self.duplication.0.is_null() {
|
||||
return Err(io::ErrorKind::AddrNotAvailable.into());
|
||||
}
|
||||
let mut frame = ptr::null_mut();
|
||||
#[allow(invalid_value)]
|
||||
let mut info = mem::MaybeUninit::uninit().assume_init();
|
||||
@@ -365,6 +465,12 @@ impl Capturer {
|
||||
let mut texture_desc = mem::MaybeUninit::uninit().assume_init();
|
||||
(*texture.0).GetDesc(&mut texture_desc);
|
||||
|
||||
let mut source = texture.0;
|
||||
if texture_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
|
||||
source = self.tonemap(texture.0, &texture_desc)?;
|
||||
(*source).GetDesc(&mut texture_desc);
|
||||
}
|
||||
|
||||
texture_desc.Usage = D3D11_USAGE_STAGING;
|
||||
texture_desc.BindFlags = 0;
|
||||
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
@@ -385,7 +491,7 @@ impl Capturer {
|
||||
&mut surface as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
|
||||
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
|
||||
(*self.context.0).CopyResource(readable.0 as *mut _, source as *mut _);
|
||||
|
||||
Ok(surface)
|
||||
}
|
||||
@@ -492,6 +598,14 @@ impl Capturer {
|
||||
let texture = ComPtr(texture);
|
||||
self.texture = texture;
|
||||
|
||||
let mut frame_desc: D3D11_TEXTURE2D_DESC = mem::zeroed();
|
||||
(*self.texture.0).GetDesc(&mut frame_desc);
|
||||
if frame_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
|
||||
let converted = self.tonemap(self.texture.0, &frame_desc)?;
|
||||
(*converted).AddRef();
|
||||
self.texture = ComPtr(converted);
|
||||
}
|
||||
|
||||
let mut final_texture = self.texture.0 as *mut c_void;
|
||||
let mut rotation = match self.display.rotation() {
|
||||
DXGI_MODE_ROTATION_ROTATE90 => 90,
|
||||
@@ -575,6 +689,9 @@ impl Capturer {
|
||||
}
|
||||
|
||||
fn unmap(&self) {
|
||||
if self.duplication.0.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
if self.fastlane {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.9
|
||||
pkgver=1.5.0
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
@@ -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", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("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();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."),
|
||||
("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."),
|
||||
("Save as", "Spara som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kppiera till urklipp"),
|
||||
("Enable remote printer", "Aktivera fjärrskrivare"),
|
||||
("Downloading {}", "Laddar ner {}"),
|
||||
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Fortsätt"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Öppnades inte webbläsaren? Använd URL:en nedan för att logga in."),
|
||||
("Lock canvas", "Lås canvas"),
|
||||
("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna 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", "ஸ்கிரீன்ஷாட்_இணைக்கப்பட்ட_திரை_ஆதரவற்ற_குறிப்பு"),
|
||||
("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", "Birden fazla ekranın ekran görüntülerinin birleştirilmesi şu anda desteklenmiyor. Lütfen tek bir ekrana geçin ve tekrar deneyin."),
|
||||
("screenshot-action-tip", "Lütfen ekran görüntüsüyle nasıl devam edeceğinizi seçin."),
|
||||
("Save as", "Farklı kaydet"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Panoya kopyala"),
|
||||
("Enable remote printer", "Uzak yazıcıyı etkinleştir"),
|
||||
("Downloading {}", "{} indiriliyor"),
|
||||
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Devam et"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Tarayıcı açılmadı mı? Giriş yapmak için aşağıdaki URL'yi kullanın."),
|
||||
("Lock canvas", "Tuvali kilitle"),
|
||||
("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"),
|
||||
("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."),
|
||||
].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.", "Браузер не відкрився? Скористайтеся посиланням нижче, щоб увійти."),
|
||||
("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", "Không hỗ trợ chụp gộp nhiều màn hình."),
|
||||
("screenshot-action-tip", "Hành động chụp màn hình"),
|
||||
("Save as", "Lưu thành"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Sao chép vào Clipboard"),
|
||||
("Enable remote printer", "Bật máy in từ xa"),
|
||||
("Downloading {}", "Đang tải xuống {}"),
|
||||
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Tiếp tục"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Trình duyệt không mở được? Hãy dùng URL bên dưới để đăng nhập."),
|
||||
("Lock canvas", "Khóa khung hình"),
|
||||
("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"),
|
||||
("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -1254,20 +1254,15 @@ pub fn get_active_userid_cached() -> Option<u32> {
|
||||
}
|
||||
|
||||
fn get_cm() -> bool {
|
||||
// We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems.
|
||||
if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() {
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
if line.contains(&format!(
|
||||
"{} --cm",
|
||||
std::env::current_exe()
|
||||
.unwrap_or("".into())
|
||||
.to_string_lossy()
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
// Runs twice a second in the service loop, so walk /proc rather than forking `ps aux`; that
|
||||
// fork is also what the `CMD_PS` audit-message workaround this replaces was for.
|
||||
let cm = format!(
|
||||
"{} --cm",
|
||||
std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
);
|
||||
any_process(None, "cmdline", |cmdline| cmdline.contains(&cm))
|
||||
}
|
||||
|
||||
pub fn is_login_wayland() -> bool {
|
||||
@@ -1576,6 +1571,34 @@ fn get_envs<'a>(
|
||||
process_pat: &str,
|
||||
names: &[&'a str],
|
||||
) -> std::collections::HashMap<&'a str, String> {
|
||||
get_envs_where(uid, process_pat, names, false, |count| count == names.len())
|
||||
}
|
||||
|
||||
/// The newest process matching `process_pat`, whatever it happens to carry: the semantics of the
|
||||
/// `ps -u <uid> -f | grep <pat> | tail -1` pipeline the callers below used before. A variable this
|
||||
/// process does not have means moving on to the next pattern, never on to an older process that
|
||||
/// may belong to a session which has since logged out.
|
||||
fn get_envs_of_newest<'a>(
|
||||
uid: &str,
|
||||
process_pat: &str,
|
||||
names: &[&'a str],
|
||||
) -> std::collections::HashMap<&'a str, String> {
|
||||
get_envs_where(uid, process_pat, names, true, |_| true)
|
||||
}
|
||||
|
||||
/// `get_envs` with the caller's own process order and its own notion of a complete answer, told
|
||||
/// how many of `names` the process carries: the first process `accept` takes wins outright, and
|
||||
/// the count-based ranking is only the fallback for when no process is accepted at all.
|
||||
fn get_envs_where<'a, F>(
|
||||
uid: &str,
|
||||
process_pat: &str,
|
||||
names: &[&'a str],
|
||||
newest_first: bool,
|
||||
mut accept: F,
|
||||
) -> std::collections::HashMap<&'a str, String>
|
||||
where
|
||||
F: FnMut(usize) -> bool,
|
||||
{
|
||||
// The tie-breaking logic uses a u64 bitmask, limiting us to 64 variables.
|
||||
debug_assert!(
|
||||
names.len() <= 64,
|
||||
@@ -1602,21 +1625,24 @@ fn get_envs<'a>(
|
||||
let mut best_count = 0usize;
|
||||
let mut best_mask: u64 = 0;
|
||||
|
||||
// Iterate /proc to find matching processes
|
||||
// Iterate /proc to find matching processes. `newest_first` is only for `get_envs_of_newest`,
|
||||
// whose callers need the last PID-ordered match their `ps ... | tail -1` pipelines took;
|
||||
// without it the order is whatever readdir returns, which is what `get_envs` has always used.
|
||||
// Neither order identifies the active session -- a user with two live graphical sessions has
|
||||
// one of each, and picking by PID guesses. See `Desktop::refresh` for who owns that question.
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return best;
|
||||
};
|
||||
let mut pids: Vec<u32> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
|
||||
.collect();
|
||||
if newest_first {
|
||||
pids.sort_unstable_by(|a, b| b.cmp(a));
|
||||
}
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(pid_str) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !pid_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let proc_path = entry.path();
|
||||
for pid in pids {
|
||||
let proc_path = std::path::Path::new("/proc").join(pid.to_string());
|
||||
|
||||
// Check if process belongs to the specified uid
|
||||
if let Ok(meta) = std::fs::metadata(&proc_path) {
|
||||
@@ -1634,15 +1660,18 @@ fn get_envs<'a>(
|
||||
continue;
|
||||
};
|
||||
let cmdline_str = String::from_utf8_lossy(&cmdline).replace('\0', " ");
|
||||
if !re.is_match(&cmdline_str) {
|
||||
// The `grep -v 'grep'` of the pipeline this replaces. A user grepping for one of these
|
||||
// patterns is otherwise the newest match for it, and answers with whatever environment
|
||||
// their shell had -- an X forwarding endpoint over ssh, say.
|
||||
if cmdline_str.contains("grep") || !re.is_match(&cmdline_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read environ and extract matching variables
|
||||
let environ_path = proc_path.join("environ");
|
||||
let Ok(environ) = std::fs::read(&environ_path) else {
|
||||
continue;
|
||||
};
|
||||
// Read environ and extract matching variables. A read that fails -- the process exited
|
||||
// between these two reads -- is a process carrying none of `names`, not a process to
|
||||
// skip: skipping it would hand `newest_first` on to an older PID, where the pipeline
|
||||
// this replaces stopped at the single PID its `tail -1` had already picked.
|
||||
let environ = std::fs::read(proc_path.join("environ")).unwrap_or_default();
|
||||
|
||||
let mut found = empty.clone();
|
||||
let mut found_count = 0usize;
|
||||
@@ -1673,14 +1702,14 @@ fn get_envs<'a>(
|
||||
found_mask |= bit;
|
||||
}
|
||||
}
|
||||
|
||||
if found_count == names.len() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accept(found_count) {
|
||||
return found;
|
||||
}
|
||||
|
||||
if found_count > best_count || (found_count == best_count && found_mask > best_mask) {
|
||||
best = found;
|
||||
best_count = found_count;
|
||||
@@ -1691,29 +1720,37 @@ fn get_envs<'a>(
|
||||
best
|
||||
}
|
||||
|
||||
/// Deprecated: Use `get_envs` instead.
|
||||
///
|
||||
/// https://github.com/rustdesk/rustdesk/discussions/11959
|
||||
///
|
||||
/// **Note**: This function is retained for conservative migration. The plan is to gradually
|
||||
/// transition all callers to `get_envs` after it proves stable and reliable. Once `get_envs`
|
||||
/// is confirmed to work correctly across all use cases, this function will be removed entirely.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - Environment variable name to retrieve
|
||||
/// * `uid` - User ID to filter processes
|
||||
/// * `process` - Process name pattern to match
|
||||
///
|
||||
/// # Returns
|
||||
/// The environment variable value, or empty string if not found
|
||||
#[inline]
|
||||
fn get_env(name: &str, uid: &str, process: &str) -> String {
|
||||
let cmd = format!("ps -u {} -f | grep -E '{}' | grep -v 'grep' | tail -1 | awk '{{print $2}}' | xargs -I__ cat /proc/__/environ 2>/dev/null | tr '\\0' '\\n' | grep '^{}=' | tail -1 | sed 's/{}=//g'", uid, process, name, name);
|
||||
if let Ok(x) = run_cmds(&cmd) {
|
||||
x.trim_end().to_string()
|
||||
} else {
|
||||
"".to_owned()
|
||||
/// True when `pred` accepts the `/proc/<pid>/<file>` of any process, NULs turned into spaces,
|
||||
/// optionally only of processes owned by `uid`.
|
||||
/// Reads `/proc` directly instead of forking `ps` / `pgrep`, for the service-loop callers below.
|
||||
fn any_process<F: Fn(&str) -> bool>(uid: Option<u32>, file: &str, pred: F) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(pid_str) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !pid_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let proc_path = entry.path();
|
||||
if let Some(uid) = uid {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
match std::fs::metadata(&proc_path) {
|
||||
Ok(meta) if meta.uid() == uid => {}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
let Ok(content) = std::fs::read(proc_path.join(file)) else {
|
||||
continue;
|
||||
};
|
||||
if pred(&String::from_utf8_lossy(&content).replace('\0', " ")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1931,12 +1968,16 @@ pub fn change_resolution_directly(name: &str, width: usize, height: usize) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scoped to `uid`, the user of the session being refreshed: the compositor starts Xwayland as
|
||||
/// that user, so another user's Xwayland -- a switched-away session, a second seat -- answering
|
||||
/// this used to route a pure-Wayland session into the Xwayland probe, which has no display for
|
||||
/// it to find. A uid that cannot be parsed falls back to the unscoped answer.
|
||||
#[inline]
|
||||
pub fn is_xwayland_running() -> bool {
|
||||
if let Ok(output) = run_cmds("pgrep -a Xwayland") {
|
||||
return output.contains("Xwayland");
|
||||
}
|
||||
false
|
||||
pub fn is_xwayland_running(uid: &str) -> bool {
|
||||
// Same test as the `pgrep -a Xwayland` this replaces: the process name, not its command line.
|
||||
any_process(uid.parse::<u32>().ok(), "comm", |comm| {
|
||||
comm.contains("Xwayland")
|
||||
})
|
||||
}
|
||||
|
||||
mod desktop {
|
||||
@@ -1959,10 +2000,14 @@ mod desktop {
|
||||
|
||||
/// A compositor that runs Xwayland without exporting `XAUTHORITY` (wlroots, e.g. Hyprland)
|
||||
/// still hands out a usable session through the Wayland side. Requiring xauth there never
|
||||
/// succeeded, so every refresh ran the retry loop to the end, 240 shell pipelines at a time.
|
||||
/// succeeded, so every refresh ran the retry loop to the end.
|
||||
/// https://github.com/rustdesk/rustdesk/issues/15952
|
||||
fn is_session_env_complete(display: &str, xauth: &str, wl_display: &str, dbus: &str) -> bool {
|
||||
!display.is_empty() && (!xauth.is_empty() || (!wl_display.is_empty() && !dbus.is_empty()))
|
||||
fn is_session_env_complete(envs: &std::collections::HashMap<&str, String>) -> bool {
|
||||
let value = |key: &str| envs.get(key).map_or("", |v| v.as_str());
|
||||
!value(ENV_KEY_DISPLAY).is_empty()
|
||||
&& (!value(ENV_KEY_XAUTHORITY).is_empty()
|
||||
|| (!value(ENV_KEY_WAYLAND_DISPLAY).is_empty()
|
||||
&& !value(ENV_KEY_DBUS_SESSION_BUS_ADDRESS).is_empty()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -2037,17 +2082,33 @@ mod desktop {
|
||||
self.dbus.clear();
|
||||
let mut kept = 0u8;
|
||||
for proc in display_proc {
|
||||
let display = get_env(ENV_KEY_DISPLAY, &self.uid, proc);
|
||||
let xauth = get_env(ENV_KEY_XAUTHORITY, &self.uid, proc);
|
||||
let wl_display = get_env(ENV_KEY_WAYLAND_DISPLAY, &self.uid, proc);
|
||||
let dbus = get_env(ENV_KEY_DBUS_SESSION_BUS_ADDRESS, &self.uid, proc);
|
||||
// Take a candidate whole and keep the best seen. Assigning each variable
|
||||
// unconditionally let a pattern that does not run on this desktop blank out
|
||||
// the values an earlier one had answered with, which is how a session with a
|
||||
// working portal ended up starting its `--server` with no compositor and no
|
||||
// bus at all. The Wayland-only rank is what a session whose Xwayland exports
|
||||
// no `XAUTHORITY` can still offer.
|
||||
let complete = is_session_env_complete(&display, &xauth, &wl_display, &dbus);
|
||||
let mut envs = get_envs_of_newest(
|
||||
&self.uid,
|
||||
proc,
|
||||
&[
|
||||
ENV_KEY_DISPLAY,
|
||||
ENV_KEY_XAUTHORITY,
|
||||
ENV_KEY_WAYLAND_DISPLAY,
|
||||
ENV_KEY_DBUS_SESSION_BUS_ADDRESS,
|
||||
],
|
||||
);
|
||||
let complete = is_session_env_complete(&envs);
|
||||
let display = envs.remove(ENV_KEY_DISPLAY).unwrap_or_default();
|
||||
let xauth = envs.remove(ENV_KEY_XAUTHORITY).unwrap_or_default();
|
||||
let wl_display = envs.remove(ENV_KEY_WAYLAND_DISPLAY).unwrap_or_default();
|
||||
let dbus = envs
|
||||
.remove(ENV_KEY_DBUS_SESSION_BUS_ADDRESS)
|
||||
.unwrap_or_default();
|
||||
// Take a candidate whole. Two graphical sessions of one user each answer
|
||||
// some of these, and a display paired with another session's xauth or
|
||||
// compositor is a pair that never existed. So rank candidates rather than
|
||||
// merge them, and keep the best seen: the later patterns are fallbacks.
|
||||
//
|
||||
// The Wayland-only rank matters when `is_xwayland_running` matched some other
|
||||
// user's Xwayland and this session has none of its own. Nothing here can then
|
||||
// answer with a display, and dropping the candidate for that would leave the
|
||||
// child server without the compositor and bus of a session that is perfectly
|
||||
// serveable through them.
|
||||
let rank = if complete {
|
||||
3
|
||||
} else if !wl_display.is_empty() && !dbus.is_empty() {
|
||||
@@ -2091,7 +2152,9 @@ mod desktop {
|
||||
SDDM_GREETER,
|
||||
];
|
||||
for proc in display_proc {
|
||||
self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc);
|
||||
self.display = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_DISPLAY])
|
||||
.remove(ENV_KEY_DISPLAY)
|
||||
.unwrap_or_default();
|
||||
if !self.display.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -2222,7 +2285,9 @@ mod desktop {
|
||||
tray.as_str(),
|
||||
];
|
||||
for proc in display_proc {
|
||||
self.xauth = get_env("XAUTHORITY", &self.uid, proc);
|
||||
self.xauth = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_XAUTHORITY])
|
||||
.remove(ENV_KEY_XAUTHORITY)
|
||||
.unwrap_or_default();
|
||||
if !self.xauth.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -2308,7 +2373,7 @@ mod desktop {
|
||||
pub fn refresh(&mut self) {
|
||||
if !self.sid.is_empty() && is_active_and_seat0(&self.sid) {
|
||||
// Xwayland display and xauth may not be available in a short time after login.
|
||||
if is_xwayland_running() && !self.is_login_wayland() {
|
||||
if is_xwayland_running(&self.uid) && !self.is_login_wayland() {
|
||||
self.get_display_xauth_xwayland();
|
||||
} else if self.is_wayland() {
|
||||
self.get_display_xauth_wayland();
|
||||
@@ -2350,7 +2415,7 @@ mod desktop {
|
||||
|
||||
self.get_home();
|
||||
if self.is_wayland() {
|
||||
if is_xwayland_running() {
|
||||
if is_xwayland_running(&self.uid) {
|
||||
self.get_display_xauth_xwayland();
|
||||
} else {
|
||||
self.get_display_xauth_wayland();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user