diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4c..2d9616a6c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,17 @@ - + + + - - + @@ -26,7 +28,6 @@ android:name=".MainApplication" android:icon="@mipmap/ic_launcher" android:label="RustDesk" - android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true"> diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 7274085fd..02cec3c25 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -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, + val rejected: Int, + val result: MethodChannel.Result + ) : PendingPicker() + } + + private data class ExportSource( + val file: File, + val children: List? + ) + + 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>()) + return + } + + val uris = linkedSetOf() + 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(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() + 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 diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index b03b63844..4648b9adc 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -214,6 +214,17 @@ class MainService : Service() { // video private var mediaProjection: MediaProjection? = null + private val mediaProjectionCallback = object : MediaProjection.Callback() { + override fun onStop() { + Log.d(logTag, "MediaProjection stopped") + stopCapture() + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + _isReady = false + checkMediaPermission() + } + } private var surface: Surface? = null private val sendVP9Thread = Executors.newSingleThreadExecutor() private var videoEncoder: MediaCodec? = null @@ -243,7 +254,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() } @@ -347,10 +360,13 @@ class MainService : Service() { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { - mediaProjection = + releaseMediaProjection() + val projection = mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it) - checkMediaPermission() + projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper())) + mediaProjection = projection _isReady = true + checkMediaPermission() } ?: let { Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") requestMediaProjection() @@ -372,6 +388,12 @@ class MainService : Service() { startActivity(intent) } + private fun releaseMediaProjection() { + mediaProjection?.unregisterCallback(mediaProjectionCallback) + mediaProjection?.stop() + mediaProjection = null + } + @SuppressLint("WrongConstant") private fun createSurface(): Surface? { return if (useVP9) { @@ -496,7 +518,7 @@ class MainService : Service() { virtualDisplay = null } - mediaProjection = null + releaseMediaProjection() checkMediaPermission() stopForeground(true) stopService(Intent(this, FloatingWindowService::class.java)) diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 514d493b9..2923cad9f 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -38,6 +38,10 @@ 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 +51,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" diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index 89e3dc046..02e6606ae 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -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) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 93c7a4d4b..25eed4259 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -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 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, diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 10459e782..ca0bd523f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -439,7 +439,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 +450,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 diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 982a4c805..e389bdf6c 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -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 { DirectoryOptions get currentOptions => currentFileController.options.value; final _uniqueKey = UniqueKey(); + Future _runAndroidDocumentPicker(Future Function() action) async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + return await action(); + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + Future _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>( + AndroidChannel.kPickImportFiles)); + if (selectedFiles == null || selectedFiles.isEmpty) return; + + for (final selected in selectedFiles) { + final uri = (selected as Map)['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 _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 _importFolder() async { + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final picked = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + 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 _exportItems(SelectedItems items) async { + await _exportPaths(items.items.map((e) => e.path)); + } + + Future _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 _exportPaths(Iterable paths) async { + try { + final result = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + 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 { ), 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 { 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 { 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), diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index cd3f97a53..d61cf70b8 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -225,12 +225,6 @@ class _ServerPageState extends State { 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 { diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 22bf1eab6..26396bce5 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -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 tryOpenReadyDirs() async { final dirs = { if (directory.value.path.isNotEmpty) directory.value.path, @@ -485,6 +498,9 @@ class FileController { } Future _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 entries) { + if (items.isEmpty) return; + final currentByPath = {for (final entry in entries) entry.path: entry}; + final reconciled = []; + 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 entries) { items.clear(); items.addAll(entries); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index bd564ba3b..e22782034 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -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 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 invokeMethodWithResult(String method, + [dynamic arguments]) async { + return await platformFFI.invokeMethodWithResult(method, arguments); + } + // Terminal model management void registerTerminalModel(int terminalId, TerminalModel model) { debugPrint('[FFI] Registering terminal model for terminal $terminalId'); diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index 8c3c5cf71..93f06d55d 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -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 invokeMethodWithResult(String method, + [dynamic arguments]) async { + if (!isAndroid) return null; + return await _toAndroidChannel.invokeMethod(method, arguments); + } + void syncAndroidServiceAppDirConfigPath() { invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); } diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 6e78ad17f..031a62509 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -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((setState, close, context) { submit() => close(true); diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index b65825e51..be8d83500 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -251,6 +251,11 @@ class PlatformFFI { return true; } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return null; + } + // just for compilation void syncAndroidServiceAppDirConfigPath() {} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 1a90336ba..84dd9cb0e 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -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: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 198036834..d67ce0003 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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 diff --git a/src/common.rs b/src/common.rs index e6993d074..648bc6b5c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -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 { + let mut tail: Vec = 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 diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 6d093cfab..1528376ab 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -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(); diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 04d982ba9..33c80e6eb 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -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 {}", "جارٍ تنزيل {}"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 6d2c93882..7a1635c27 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -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 {}", "Ідзе спампоўванне {}"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 83c98545e..d9f7cf842 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -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 {}", "Изтегляне на {}"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 9b0ebb085..196574688 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -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 {}"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 191c25908..be998606b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -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 {}", "正在下载 {}"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 1d214e024..21daea69c 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -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 {}"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 38447d724..b29e3eddf 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -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 {}"), diff --git a/src/lang/de.rs b/src/lang/de.rs index 833be3fca..9c6e75bc6 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -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"), diff --git a/src/lang/el.rs b/src/lang/el.rs index cc7591ea3..d3d1e378f 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -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 {}", "Γίνεται Λήψη {}"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 48a49f96d..4f9b0ccd7 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -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 {}"), diff --git a/src/lang/es.rs b/src/lang/es.rs index b481fce7f..89926b43a 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -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 {}"), diff --git a/src/lang/et.rs b/src/lang/et.rs index d916df419..9bbb7b07d 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -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: {}"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index e74f3a285..8515e6f53 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -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"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index c9fd7b45e..a96fe7160 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -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 {}", "بارگیری {}"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 9cb8e8de1..d5695d18d 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -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 {}"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 5b3204053..4dece7adc 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -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 {}"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 988570095..a422c4853 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -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 {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 7825c5204..3a1c14139 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -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 {}", "{} ડાઉનલોડ થઈ રહ્યું છે"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 1183a3cbb..2ff95b48f 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -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 {}", "מוריד את {}"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d73b381c0..da7fc6a40 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -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 {}", "{} डाउनलोड हो रहा है"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 7a0f9d3cf..20f5b0da1 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -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 {}"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 705dc867c..28f3d0482 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -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"), diff --git a/src/lang/id.rs b/src/lang/id.rs index 25c12040d..8c7af75e4 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -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 {}"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 330e5577a..8de645fbe 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -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 {}"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index f9ae7777e..71713ca01 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -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 {}", "{} をダウンロード中"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f7da53b3f..abd48fb9e 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -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 {}", "{} 다운로드 중"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 89121acca..b623e5c33 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -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 {}", "{} жүктелуде"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index eb19f21c2..45d2ddc08 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -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 {}"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index fe853cdff..a71cdc038 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -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"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index fe8534a0e..157a7abb3 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -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 {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 45bd5c540..e92c47eb2 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -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 {}"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index b0d21f97f..813bb2110 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -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 {}"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 120183803..37144a0bc 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -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 {}"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 7d033b363..94043d38d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -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 {}"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 897ef1735..a7879960d 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -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 {}"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index aee37cf94..03eb282f5 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -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ă {}"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 834fcd565..8d29105db 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -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 {}", "Скачивание"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 59d0967c6..8be034c4b 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -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 {}"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index f01cf6e3a..cf17d2129 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -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 {}"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 04a0dd0e2..6d4480fe5 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -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 {}"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 2fb1c811d..470708082 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -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 {}"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index e1b0e703d..fe3d047e5 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -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 {}"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 594efa688..9f2efc263 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -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 {}"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 8a4afde95..ac2486ccb 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -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 {}", "{} பதிவிறக்குகிறது"), diff --git a/src/lang/template.rs b/src/lang/template.rs index 83497a0f6..b65c92793 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -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 {}", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index 31f314726..7531d072d 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -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 {}", "กำลังดาวน์โหลด {}"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 66ac42a1c..8546d96bc 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -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"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b35322d10..8663c5d5f 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -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 {}", "正在下載 {} 並安裝新版本。"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 281b9cd6c..c11eac1f6 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -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 {}", "Завантаження {}"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index c9b28b949..7f4e0ef46 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -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 {}"), diff --git a/src/server/connection.rs b/src/server/connection.rs index adcab4c88..bcdae795a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2021,11 +2021,17 @@ impl Connection { self.update_scoped_login_options().await; if let Some((dir, show_hidden)) = self.file_transfer.clone() { self.keyboard = false; - let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() { - &dir - } else { - "" - }; + let is_existing_dir = !dir.is_empty() && std::path::Path::new(&dir).is_dir(); + let is_allowed_dir = + is_existing_dir && crate::common::is_peer_path_allowed(&dir, false); + #[cfg(target_os = "android")] + if is_existing_dir && !is_allowed_dir { + log::warn!( + "Use the app workspace because the initial file-transfer directory is outside it: {}", + dir + ); + } + let dir = if is_allowed_dir { &dir } else { "" }; if !wait_session_id_confirm { self.read_dir(dir, show_hidden); } else { @@ -3313,6 +3319,81 @@ impl Connection { return true; } } + // Android is scoped-storage only: reject any peer supplied path that + // escapes the app workspace before it reaches the filesystem. + #[cfg(target_os = "android")] + { + // (path, job id, allow empty) of the peer supplied path this action + // operates on. + let checked: Option<(&str, i32, bool)> = match &fa.union { + Some(file_action::Union::ReadEmptyDirs(rd)) => { + Some((rd.path.as_str(), -1, false)) + } + Some(file_action::Union::ReadDir(rd)) => { + Some((rd.path.as_str(), 0, true)) + } + Some(file_action::Union::AllFiles(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Send(s)) => { + // Printer jobs read from memory, `path` is only a lookup key. + if JobType::from_proto(s.file_type) == JobType::Generic { + Some((s.path.as_str(), s.id, false)) + } else { + None + } + } + Some(file_action::Union::Receive(r)) => { + Some((r.path.as_str(), r.id, false)) + } + Some(file_action::Union::RemoveDir(d)) => { + Some((d.path.as_str(), d.id, false)) + } + Some(file_action::Union::RemoveFile(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Create(c)) => { + Some((c.path.as_str(), c.id, false)) + } + Some(file_action::Union::Rename(r)) => { + Some((r.path.as_str(), r.id, false)) + } + _ => None, + }; + if let Some((path, job_id, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!( + "Reject file action outside the app workspace: {}", + path + ); + if job_id >= 0 { + self.send(fs::new_error(job_id, "Permission denied", -1)) + .await; + } + return true; + } + } + if let Some(file_action::Union::Rename(r)) = &fa.union { + let destination = std::path::Path::new(&r.path) + .parent() + .map(|parent| parent.join(&r.new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + self.send(fs::new_error(r.id, "Permission denied", -1)) + .await; + return true; + } + } + } match fa.union { Some(file_action::Union::ReadEmptyDirs(rd)) => { self.read_empty_dirs(&rd.path, rd.include_hidden); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 5e13ef82b..c659170e3 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -977,6 +977,61 @@ async fn handle_fs( tx_log: Option<&UnboundedSender>, _conn_id: i32, ) { + // Android is scoped-storage only, so every peer supplied path has to stay inside the + // app workspace. This is the filesystem boundary, keep it enforced here even though + // `Connection` rejects out-of-workspace requests earlier as well. + #[cfg(target_os = "android")] + { + // (path, job id, file num, allow empty) of the peer supplied path this message + // acts on. + let checked: Option<(&str, i32, i32, bool)> = match &fs { + ipc::FS::ReadEmptyDirs { dir, .. } => Some((dir.as_str(), -1, -1, false)), + ipc::FS::ReadDir { dir, .. } => Some((dir.as_str(), -1, -1, true)), + ipc::FS::RemoveDir { path, id, .. } | ipc::FS::CreateDir { path, id } => { + Some((path.as_str(), *id, 0, false)) + } + ipc::FS::Rename { path, id, .. } => Some((path.as_str(), *id, 0, false)), + ipc::FS::RemoveFile { path, id, file_num } => { + Some((path.as_str(), *id, *file_num, false)) + } + ipc::FS::ReadAllFiles { path, id, .. } => Some((path.as_str(), *id, -1, false)), + ipc::FS::NewWrite { + path, id, file_num, .. + } + | ipc::FS::ReadFile { + path, id, file_num, .. + } => Some((path.as_str(), *id, *file_num, false)), + _ => None, + }; + if let Some((path, id, file_num, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!("Reject file operation outside the app workspace: {}", path); + if id >= 0 { + send_raw(fs::new_error(id, "Permission denied", file_num), tx); + } + return; + } + } + if let ipc::FS::Rename { path, new_name, id } = &fs { + let destination = std::path::Path::new(path) + .parent() + .map(|parent| parent.join(new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + send_raw(fs::new_error(*id, "Permission denied", 0), tx); + return; + } + } + } match fs { ipc::FS::ReadEmptyDirs { dir,