From b5131f56d8ff4c8b79833dc16ebc83a71164c7e0 Mon Sep 17 00:00:00 2001 From: AmirHossein Abdolmotallebi Date: Sat, 19 Oct 2024 18:26:29 +0330 Subject: [PATCH] add proxy --- .../com/abdownloadmanager/desktop/di/Di.kt | 29 +- .../pages/batchdownload/BatchDownnload.kt | 44 +- .../pages/settings/SettingsComponent.kt | 26 ++ .../settings/configurable/Configurable.kt | 19 + .../settings/configurable/widgets/Proxy.kt | 427 ++++++++++++++++++ .../widgets/RenderConfigurable.kt | 1 + .../desktop/repository/AppRepository.kt | 3 + .../desktop/storage/AppSettingsStorage.kt | 2 +- .../desktop/storage/PageStatesStorage.kt | 2 +- .../desktop/storage/ProxyDatastoreStorage.kt | 14 + .../desktop/ui/widget/Multiselect.kt | 66 +++ .../desktop/ui/widget/NumberTextField.kt | 16 +- .../desktop/utils/AppInfo.kt | 1 + .../desktop/utils/BaseSettings.kt | 73 ++- desktop/shared/build.gradle.kts | 5 + .../ir/amirab/util/desktop/DesktopUtils.kt | 23 + .../amirab/util/desktop/linux/LinuxUtils.kt | 31 ++ .../ir/amirab/util/desktop/mac/MacOSUtils.kt | 12 + .../util/desktop/windows/WindowsUtils.kt | 20 + .../connection/OkHttpDownloaderClient.kt | 109 +++-- .../downloader/connection/proxy/Proxy.kt | 22 + .../connection/proxy/ProxyStrategy.kt | 7 + .../connection/proxy/ProxyStrategyProvider.kt | 5 + .../downloader/connection/proxy/ProxyType.kt | 11 + .../utils/category/Category.kt | 16 +- .../utils/proxy/IProxyStorage.kt | 7 + .../abdownloadmanager/utils/proxy/Proxy.kt | 55 +++ .../utils/proxy/ProxyManager.kt | 72 +++ .../KotlinSerializationDataStore.kt | 2 +- ...nfigDataStore.kt => MapConfigDataStore.kt} | 2 +- .../ir/amirab/util/{osfileutil => }/Exec.kt | 4 +- .../main/kotlin/ir/amirab/util/StringUtil.kt | 12 + .../amirab/util/osfileutil/LinuxFileUtils.kt | 1 + .../amirab/util/osfileutil/MacOsFileUtils.kt | 1 + .../util/osfileutil/WindowsFileUtils.kt | 1 + 35 files changed, 1022 insertions(+), 119 deletions(-) create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/Proxy.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/ProxyDatastoreStorage.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Multiselect.kt create mode 100644 desktop/shared/src/main/kotlin/ir/amirab/util/desktop/DesktopUtils.kt create mode 100644 desktop/shared/src/main/kotlin/ir/amirab/util/desktop/linux/LinuxUtils.kt create mode 100644 desktop/shared/src/main/kotlin/ir/amirab/util/desktop/mac/MacOSUtils.kt create mode 100644 desktop/shared/src/main/kotlin/ir/amirab/util/desktop/windows/WindowsUtils.kt create mode 100644 downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/Proxy.kt create mode 100644 downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategy.kt create mode 100644 downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategyProvider.kt create mode 100644 downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyType.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/IProxyStorage.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/Proxy.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/ProxyManager.kt rename {desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/base => shared/config/src/main/kotlin/ir/amirab/util/config/datastore}/KotlinSerializationDataStore.kt (97%) rename shared/config/src/main/kotlin/ir/amirab/util/config/datastore/{ConfigDataStore.kt => MapConfigDataStore.kt} (98%) rename shared/utils/src/main/kotlin/ir/amirab/util/{osfileutil => }/Exec.kt (92%) create mode 100644 shared/utils/src/main/kotlin/ir/amirab/util/StringUtil.kt diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt index 762f9e2..ed965a5 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt @@ -24,7 +24,7 @@ import ir.amirab.downloader.utils.IDiskStat import ir.amirab.util.startup.Startup import com.abdownloadmanager.integration.Integration import ir.amirab.downloader.DownloadManager -import ir.amirab.util.config.datastore.createMyConfigPreferences +import ir.amirab.util.config.datastore.createMapConfigDatastore import kotlinx.coroutines.* import kotlinx.serialization.json.Json import okhttp3.Dispatcher @@ -39,8 +39,13 @@ import com.abdownloadmanager.utils.FileIconProvider import com.abdownloadmanager.utils.FileIconProviderUsingCategoryIcons import com.abdownloadmanager.utils.category.* import com.abdownloadmanager.utils.compose.IMyIcons +import com.abdownloadmanager.utils.proxy.IProxyStorage +import com.abdownloadmanager.utils.proxy.ProxyData +import com.abdownloadmanager.utils.proxy.ProxyManager +import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.utils.EmptyFileCreator +import ir.amirab.util.config.datastore.kotlinxSerializationDataStore val downloaderModule = module { single { @@ -84,6 +89,11 @@ val downloaderModule = module { 8, ) } + single { + ProxyManager( + get() + ) + }.bind() single { OkHttpDownloaderClient( OkHttpClient @@ -92,9 +102,9 @@ val downloaderModule = module { //bypass limit on concurrent connections! maxRequests = Int.MAX_VALUE maxRequestsPerHost = Int.MAX_VALUE - }).build() + }).build(), + get() ) - } single { val downloadSettings: DownloadSettings = get() @@ -215,9 +225,18 @@ val appModule = module { single { MyIcons }.bind() + single { + ProxyDatastoreStorage( + kotlinxSerializationDataStore( + AppInfo.optionsDir.resolve("proxySettings.json"), + get(), + ProxyData::default, + ) + ) + }.bind() single { AppSettingsStorage( - createMyConfigPreferences( + createMapConfigDatastore( AppInfo.configDir.resolve("appSettings.json"), get(), ) @@ -225,7 +244,7 @@ val appModule = module { } single { PageStatesStorage( - createMyConfigPreferences( + createMapConfigDatastore( AppInfo.configDir.resolve("pageStatesStorage.json"), get(), ) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownnload.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownnload.kt index 98be55f..5cdc268 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownnload.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownnload.kt @@ -223,7 +223,7 @@ private fun WildcardLengthUi( verticalAlignment = Alignment.CenterVertically ) { Multiselect( - selections = WildcardSelect.entries, + selections = entries, selectedItem = WildcardSelect.fromWildcardLength(wildcardLength), onSelectionChange = { onChangeWildcardLength( @@ -258,48 +258,6 @@ private fun WildcardLengthUi( } } -@Composable -private fun Multiselect( - selections: List, - selectedItem: T, - onSelectionChange: (T) -> Unit, - render: @Composable (T) -> Unit, -) { - val shape = RoundedCornerShape(6.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(shape) - .background(myColors.surface) - ) { - for (item in selections) { - val isSelected = item == selectedItem - Box( - Modifier - .padding(vertical = 4.dp, horizontal = 4.dp) - .clip(shape) - .ifThen(isSelected) { - background(LocalContentColor.current / 10) - } - .clickable { - onSelectionChange(item) - } - .padding(vertical = 2.dp, horizontal = 4.dp) - ) { - WithContentAlpha( - if (isSelected) { - 1f - } else { - 0.5f - } - ) { - render(item) - } - } - } - } -} - @Composable private fun LabeledContent( label: @Composable () -> Unit, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingsComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingsComponent.kt index 183cd68..e1a9962 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingsComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingsComponent.kt @@ -11,6 +11,8 @@ import com.abdownloadmanager.desktop.utils.convertSpeedToHumanReadable import com.abdownloadmanager.desktop.utils.mvi.ContainsEffects import com.abdownloadmanager.desktop.utils.mvi.supportEffects import androidx.compose.runtime.* +import com.abdownloadmanager.utils.proxy.ProxyManager +import com.abdownloadmanager.utils.proxy.ProxyMode import com.arkivanov.decompose.ComponentContext import ir.amirab.util.osfileutil.FileUtils import ir.amirab.util.flow.createMutableStateFlowFromStateFlow @@ -133,6 +135,28 @@ fun defaultDownloadFolderConfig(appSettings: AppSettingsStorage): FolderConfigur ) } +fun proxyConfig(proxyManager: ProxyManager, scope: CoroutineScope): ProxyConfigurable { + return ProxyConfigurable( + title = "Use Proxy", + description = "Use proxy for downloading files", + backedBy = proxyManager.proxyData, + + validate = { + true + }, + describe = { + val str = when (it.proxyMode) { + ProxyMode.Direct -> "No proxy" + ProxyMode.UseSystem -> "System proxy" + ProxyMode.Manual -> it.proxyWithRules.proxy.run { + "$type $host:$port" + } + } + "$str will be used" + } + ) +} + /* fun uiScaleConfig(appSettings: AppSettings): EnumConfigurable { return EnumConfigurable( @@ -270,6 +294,7 @@ class SettingsComponent( ContainsEffects by supportEffects() { val appSettings by inject() val appRepository by inject() + val proxyManager by inject() val themeManager by inject() val allConfigs = object : SettingSectionGetter { override operator fun get(key: SettingSections): List> { @@ -290,6 +315,7 @@ class SettingsComponent( DownloadEngine -> listOf( defaultDownloadFolderConfig(appSettings), + proxyConfig(proxyManager, scope), useAverageSpeedConfig(appRepository), speedLimitConfig(appRepository), threadCountConfig(appRepository), diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt index 7da750b..86fe37e 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import com.abdownloadmanager.desktop.pages.settings.ThemeInfo import com.abdownloadmanager.desktop.pages.settings.configurable.BooleanConfigurable.RenderMode import com.abdownloadmanager.desktop.ui.theme.MyColors +import com.abdownloadmanager.utils.proxy.ProxyData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -298,3 +299,21 @@ class DayOfWeekConfigurable( enabled = enabled, visible = visible, ) + +class ProxyConfigurable( + title: String, + description: String, + backedBy: MutableStateFlow, + describe: (ProxyData) -> String, + validate: (ProxyData) -> Boolean, + enabled: StateFlow = DefaultEnabledValue, + visible: StateFlow = DefaultVisibleValue, +) : Configurable( + title = title, + description = description, + backedBy = backedBy, + describe = describe, + validate = validate, + enabled = enabled, + visible = visible, +) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/Proxy.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/Proxy.kt new file mode 100644 index 0000000..d316770 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/Proxy.kt @@ -0,0 +1,427 @@ +package com.abdownloadmanager.desktop.pages.settings.configurable.widgets + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.abdownloadmanager.desktop.pages.settings.configurable.ProxyConfigurable +import com.abdownloadmanager.desktop.ui.icon.MyIcons +import com.abdownloadmanager.desktop.ui.theme.myColors +import com.abdownloadmanager.desktop.ui.theme.myTextSizes +import com.abdownloadmanager.desktop.ui.widget.* +import com.abdownloadmanager.desktop.utils.div +import com.abdownloadmanager.utils.compose.LocalContentColor +import com.abdownloadmanager.utils.compose.widget.Icon +import com.abdownloadmanager.utils.compose.widget.MyIcon +import com.abdownloadmanager.utils.proxy.ProxyMode +import com.abdownloadmanager.utils.proxy.ProxyRules +import com.abdownloadmanager.utils.proxy.ProxyWithRules +import ir.amirab.downloader.connection.proxy.Proxy +import ir.amirab.downloader.connection.proxy.ProxyType +import ir.amirab.util.desktop.DesktopUtils + + +@Composable +fun RenderProxyConfig(cfg: ProxyConfigurable, modifier: Modifier) { + val value by cfg.stateFlow.collectAsState() + val setValue = cfg::set + val enabled = isConfigEnabled() + ConfigTemplate( + modifier = modifier, + title = { + TitleAndDescription(cfg, true) + }, + value = { + RenderSpinner( + enabled = enabled, + possibleValues = ProxyMode.usableValues(), + value = value.proxyMode, + onSelect = { + setValue( + value.copy( + proxyMode = it + ) + ) + }, + modifier = Modifier.widthIn(min = 120.dp), + render = { + val text = when (it) { + ProxyMode.Direct -> "No Proxy" + ProxyMode.UseSystem -> "Use System Proxy" + ProxyMode.Manual -> "Manual Proxy" + } + Text(text) + }, + ) + }, + nestedContent = { + AnimatedContent(value.proxyMode.takeIf { enabled }) { + when (it) { + ProxyMode.Direct -> {} + ProxyMode.UseSystem -> { + ActionButton( + "Open System Proxy Settings", + onClick = { + DesktopUtils.openSystemProxySettings() + }, + ) + } + + ProxyMode.Manual -> { + RenderManualProxyConfig( + proxyWithRules = value.proxyWithRules, + setProxyWithRules = { + setValue( + value.copy( + proxyWithRules = it + ) + ) + } + ) + } + + null -> {} + } + } + } + ) +} + +@Stable +private class ProxyEditState( + private val proxyWithRules: ProxyWithRules, + private val setProxyWithRules: (ProxyWithRules) -> Unit, +) { + var proxyType = mutableStateOf(proxyWithRules.proxy.type) + + var proxyHost = mutableStateOf(proxyWithRules.proxy.host) + var proxyPort = mutableStateOf(proxyWithRules.proxy.port) + + var useAuth = mutableStateOf(proxyWithRules.proxy.username != null) + var proxyUsername = mutableStateOf(proxyWithRules.proxy.username.orEmpty()) + var proxyPassword = mutableStateOf(proxyWithRules.proxy.password.orEmpty()) + + var excludeURLPatterns = mutableStateOf(proxyWithRules.rules.excludeURLPatterns.joinToString(" ")) + + val canSave: Boolean by derivedStateOf { + val hostValid = proxyHost.value.isNotBlank() + hostValid + } + + fun save() { + val useAuth = useAuth.value + if (!canSave) { + return + } + setProxyWithRules( + proxyWithRules.copy( + proxy = Proxy( + type = proxyType.value, + host = proxyHost.value.trim(), + port = proxyPort.value, + username = proxyUsername.value.takeIf { it.isNotEmpty() && useAuth }, + password = proxyPassword.value.takeIf { it.isNotEmpty() && useAuth }, + ), + rules = ProxyRules( + excludeURLPatterns = excludeURLPatterns.value + .split(" ") + .map { it.trim() } + .filterNot { it.isEmpty() }, + ) + ) + ) + } +} + +@Composable +fun RenderManualProxyConfig( + proxyWithRules: ProxyWithRules, + setProxyWithRules: (ProxyWithRules) -> Unit, +) { + var showManualProxyConfig by remember { + mutableStateOf(false) + } + ActionButton( + "Change proxy", + onClick = { + showManualProxyConfig = true + }, + ) + if (showManualProxyConfig) { + val dismiss = { + showManualProxyConfig = false + } + val state = remember(setProxyWithRules) { + ProxyEditState( + proxyWithRules = proxyWithRules, + setProxyWithRules = { + setProxyWithRules(it) + dismiss() + } + ) + } + ProxyEditDialog(state, onDismiss = dismiss) + } +} + +@Composable +private fun ProxyEditDialog( + state: ProxyEditState, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = (onDismiss), + content = { + val (type, setType) = state.proxyType + val (host, setHost) = state.proxyHost + val (port, setPort) = state.proxyPort + val (useAuth, setUseAuth) = state.useAuth + val (username, setUsername) = state.proxyUsername + val (password, setPassword) = state.proxyPassword + val (excludeURLPatterns, setExcludeURLPatterns) = state.excludeURLPatterns + + SettingsDialog( + headerTitle = "Edit Proxy", + onDismiss = onDismiss, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + ) { + val spacer = @Composable { + Spacer(Modifier.height(8.dp)) + } + DialogConfigItem( + modifier = Modifier, + title = { + Text("Type") + }, + value = { + Multiselect( + selections = ProxyType.entries.toList(), + selectedItem = type, + onSelectionChange = setType, + modifier = Modifier, + render = { + Text( + it.name, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + ) + }, + selectedColor = LocalContentColor.current / 15, + unselectedAlpha = 0.8f, + ) + } + ) + spacer() + DialogConfigItem( + modifier = Modifier, + title = { + Text("Address & Port") + }, + value = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MyTextField( + text = host, + onTextChange = setHost, + placeholder = "127.0.0.1", + modifier = Modifier.weight(1f), + ) + Text(":", Modifier.padding(horizontal = 8.dp)) + IntTextField( + value = port, + onValueChange = setPort, + placeholder = "Port", + range = 1..65535, + modifier = Modifier.width(96.dp), + keyboardOptions = KeyboardOptions(), + textPadding = PaddingValues(8.dp), + shape = RoundedCornerShape(12.dp), + ) + } + } + ) + spacer() + DialogConfigItem( + modifier = Modifier, + title = { + Row( + modifier = Modifier.onClick { + setUseAuth(!useAuth) + } + ) { + CheckBox( + value = useAuth, + onValueChange = setUseAuth, + size = 16.dp + ) + Spacer(Modifier.width(8.dp)) + Text("Use Authentication") + } + }, + value = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MyTextField( + text = username, + onTextChange = setUsername, + placeholder = "Username", + modifier = Modifier.weight(1f), + enabled = useAuth, + ) + Spacer(Modifier.width(8.dp)) + MyTextField( + text = password, + onTextChange = setPassword, + placeholder = "Password", + modifier = Modifier.weight(1f), + enabled = useAuth, + ) + } + } + ) + spacer() + DialogConfigItem( + modifier = Modifier, + title = { + Row { + Text("Don't Use proxy for") + Spacer(Modifier.width(8.dp)) + Help( + "A list of urls that may not be proxied\nYou can use wildcard with *\nfor example 192.168.1.* example.com (space separated)" + ) + } + }, + value = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MyTextField( + text = excludeURLPatterns, + onTextChange = setExcludeURLPatterns, + placeholder = "example.com 192.168.1.*", + modifier = Modifier, + ) + } + } + ) + } + }, + actions = { + ActionButton( + "Save", + enabled = state.canSave, + onClick = { + state.save() + }) + Spacer(Modifier.width(8.dp)) + ActionButton("Cancel", onClick = { + onDismiss() + }) + } + ) + } + ) +} + +@Composable +private fun SettingsDialog( + headerTitle: String, + onDismiss: () -> Unit, + content: @Composable () -> Unit, + actions: (@Composable RowScope.() -> Unit)? = null, +) { + val shape = RoundedCornerShape(6.dp) + Column( + modifier = Modifier + .clip(shape) + .border(2.dp, myColors.onBackground / 10, shape) + .background( + Brush.linearGradient( + listOf( + myColors.surface, + myColors.background, + ) + ) + ) + .padding(16.dp) + .width(450.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + headerTitle, + fontSize = myTextSizes.lg, + fontWeight = FontWeight.Bold, + ) + MyIcon( + MyIcons.windowClose, + "Close", + Modifier + .clip(CircleShape) + .clickable { onDismiss() } + .padding(12.dp) + .size(12.dp), + ) + } + Spacer(Modifier.height(8.dp)) + content() + actions?.let { + Spacer(Modifier.height(8.dp)) + Row( + Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + actions() + } + } + } +} + +@Composable +private fun DialogConfigItem( + modifier: Modifier, + title: @Composable ColumnScope.() -> Unit, + value: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier, + ) { + Column( + Modifier + .height(IntrinsicSize.Max), + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + title() + } + Spacer(Modifier.height(8.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + ) { + value() + } + } + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt index a30efb2..07a7e11 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt @@ -48,6 +48,7 @@ fun RenderConfigurable( } is DayOfWeekConfigurable -> RenderDayOfWeekConfigurable(cfg,modifier) + is ProxyConfigurable -> RenderProxyConfig(cfg, modifier) } } } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/repository/AppRepository.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/repository/AppRepository.kt index 80b36b5..241101e 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/repository/AppRepository.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/repository/AppRepository.kt @@ -6,6 +6,7 @@ import com.abdownloadmanager.desktop.utils.DownloadSystem import ir.amirab.downloader.DownloadSettings import com.abdownloadmanager.integration.Integration import com.abdownloadmanager.integration.IntegrationResult +import com.abdownloadmanager.utils.proxy.ProxyManager import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.monitor.IDownloadMonitor import kotlinx.coroutines.CoroutineScope @@ -19,6 +20,7 @@ import org.koin.core.component.inject class AppRepository : KoinComponent { private val scope: CoroutineScope by inject() private val appSettings: AppSettingsStorage by inject() + private val proxyManager: ProxyManager by inject() val theme = appSettings.theme // val uiScale = appSettings.uiScale @@ -38,6 +40,7 @@ class AppRepository : KoinComponent { val integrationEnabled = appSettings.browserIntegrationEnabled val integrationPort = appSettings.browserIntegrationPort + init { //maybe its better to move this to another place appSettings.autoStartOnBoot diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/AppSettingsStorage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/AppSettingsStorage.kt index 6207c33..8b2dbe2 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/AppSettingsStorage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/AppSettingsStorage.kt @@ -96,7 +96,7 @@ data class AppSettingsModel( class AppSettingsStorage( settings: DataStore, -) : ConfigBaseSettings(settings, AppSettingsModel.ConfigLens) { +) : ConfigBaseSettingsByMapConfig(settings, AppSettingsModel.ConfigLens) { var theme = from(AppSettingsModel.theme) var mergeTopBarWithTitleBar = from(AppSettingsModel.mergeTopBarWithTitleBar) val threadCount = from(AppSettingsModel.threadCount) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/PageStatesStorage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/PageStatesStorage.kt index 947fca3..c07011e 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/PageStatesStorage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/PageStatesStorage.kt @@ -84,7 +84,7 @@ data class PageStatesModel( class PageStatesStorage( settings: DataStore, -) : ConfigBaseSettings(settings, PageStatesModel.ConfigLens) { +) : ConfigBaseSettingsByMapConfig(settings, PageStatesModel.ConfigLens) { val lastUsedSaveLocations = from(PageStatesModel.global.lastSavedLocations) val downloadPage = from(PageStatesModel.downloadPage) val homePageStorage = from(PageStatesModel.home) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/ProxyDatastoreStorage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/ProxyDatastoreStorage.kt new file mode 100644 index 0000000..16a8024 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/ProxyDatastoreStorage.kt @@ -0,0 +1,14 @@ +package com.abdownloadmanager.desktop.storage + +import androidx.datastore.core.DataStore +import com.abdownloadmanager.desktop.utils.ConfigBaseSettingsByJson +import com.abdownloadmanager.utils.proxy.IProxyStorage +import com.abdownloadmanager.utils.proxy.ProxyData +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class ProxyDatastoreStorage( + dataStore: DataStore, +) : IProxyStorage, ConfigBaseSettingsByJson(dataStore) { + override val proxyDataFlow = data +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Multiselect.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Multiselect.kt new file mode 100644 index 0000000..9afa0bd --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Multiselect.kt @@ -0,0 +1,66 @@ +package com.abdownloadmanager.desktop.ui.widget + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.abdownloadmanager.desktop.ui.theme.myColors +import com.abdownloadmanager.desktop.ui.util.ifThen +import com.abdownloadmanager.desktop.utils.div +import com.abdownloadmanager.utils.compose.LocalContentColor +import com.abdownloadmanager.utils.compose.WithContentAlpha + +@Composable +fun Multiselect( + selections: List, + selectedItem: T, + onSelectionChange: (T) -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(6.dp), + backgroundColour: Color = myColors.surface, + selectedColor: Color = LocalContentColor.current / 10, + unselectedAlpha: Float = 0.5f, + render: @Composable (T) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(shape) + .background(backgroundColour) + ) { + for (item in selections) { + val isSelected = item == selectedItem + Box( + Modifier + .padding(vertical = 4.dp, horizontal = 4.dp) + .clip(shape) + .ifThen(isSelected) { + background(selectedColor) + } + .clickable { + onSelectionChange(item) + } + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + WithContentAlpha( + if (isSelected) { + 1f + } else { + unselectedAlpha + } + ) { + render(item) + } + } + } + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/NumberTextField.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/NumberTextField.kt index 0d4ef9f..174867c 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/NumberTextField.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/NumberTextField.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent @@ -33,6 +34,8 @@ fun IntTextField( keyboardOptions: KeyboardOptions, prettify: (Int) -> String = { it.toString() }, placeholder: String = "", + textPadding: PaddingValues = PaddingValues(4.dp), + shape: Shape = RectangleShape, ) { NumberTextField( value = value, @@ -53,6 +56,8 @@ fun IntTextField( keyboardOptions = keyboardOptions, interactionSource = interactionSource, placeholder = placeholder, + textPadding = textPadding, + shape = shape, ) } @@ -103,7 +108,7 @@ fun DoubleTextField( }, enabled: Boolean = true, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - placeholder: String = "" + placeholder: String = "", ) { NumberTextField( value = value, @@ -177,10 +182,11 @@ fun > NumberTextField( keyboardOptions: KeyboardOptions, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, placeholder: String = "", + textPadding: PaddingValues = PaddingValues(4.dp), + shape: Shape = RectangleShape, ) { val value by rememberUpdatedState(value) - val shape = RectangleShape val isFocused by interactionSource.collectIsFocusedAsState() var haveWrongValue by remember(value) { mutableStateOf(false) @@ -203,7 +209,7 @@ fun > NumberTextField( myText = prettify(value) } } - fun set(v:T,prettify: Boolean):Boolean{ + fun set(v: T, prettify: Boolean): Boolean { val isInRange = v in range val valueInRange = if (isInRange) v else v.coerceIn(range) lastEmittedValueByMe = if (prettify || !isInRange) { @@ -224,7 +230,7 @@ fun > NumberTextField( } } MyTextField( - textPadding = PaddingValues(4.dp), + textPadding = textPadding, shape = shape, modifier = modifier.onKeyEvent { when (it.key) { @@ -258,7 +264,7 @@ fun > NumberTextField( myText = it } else { val wasInRange = set(v, false) - if (wasInRange){ + if (wasInRange) { myText = it } } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt index 5b34767..434d7ff 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt @@ -35,6 +35,7 @@ fun AppInfo.isInDebugMode(): Boolean { } val AppInfo.configDir: File get() = File(AppProperties.getConfigDirectory()) +val AppInfo.optionsDir: File get() = AppInfo.configDir.resolve("options") val AppInfo.downloadDbDir:File get() = AppInfo.configDir.resolve("download_db") fun AppInfo.extensions(){ diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/BaseSettings.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/BaseSettings.kt index 950671e..39d48b7 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/BaseSettings.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/BaseSettings.kt @@ -10,34 +10,69 @@ import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.inject -abstract class ConfigBaseSettings( - dataStore: DataStore, - lens: Lens -) : KoinComponent { - private val scope: CoroutineScope by inject() - private val lastFileState = dataStore.data.let { - runBlocking { it.stateIn(scope) } - } +abstract class BaseStorage : KoinComponent { + val scope: CoroutineScope by inject() - private val inMemoryState = MutableStateFlow( - lens.get(lastFileState.value) - ) + protected abstract val inMemoryState: MutableStateFlow + protected abstract suspend fun saveData(data: T) - init { + val data get() = inMemoryState + + fun from(lens: Lens): MutableStateFlow { + return inMemoryState.mapTwoWayStateFlow(lens) + } + + /** + * call this on upper implementations where [inMemoryState] and [saveData] are implemented + */ + protected fun startPersistData() { inMemoryState //first .drop(1) .debounce(500) .onEach { s -> - dataStore.updateData { - val newData = lens.set(MapConfig(), s) - newData - } + saveData(s) }.launchIn(scope) } +} - fun from(lens: Lens): MutableStateFlow { - return inMemoryState - .mapTwoWayStateFlow(lens) +abstract class ConfigBaseSettingsByMapConfig( + private val dataStore: DataStore, + private val lens: Lens, +) : BaseStorage(), KoinComponent { + private val lastFileState = dataStore.data.let { + runBlocking { it.stateIn(scope) } + } + + override val inMemoryState = MutableStateFlow( + lens.get(lastFileState.value) + ) + + override suspend fun saveData(data: T) { + dataStore.updateData { + val newData = lens.set(MapConfig(), data) + newData + } + } + + init { + startPersistData() + } +} + +abstract class ConfigBaseSettingsByJson( + private val dataStore: DataStore, +) : BaseStorage(), KoinComponent { + private val lastFileState = dataStore.data.let { + runBlocking { it.stateIn(scope) } + } + + override val inMemoryState = MutableStateFlow(lastFileState.value) + override suspend fun saveData(data: T) { + dataStore.updateData { data } + } + + init { + startPersistData() } } \ No newline at end of file diff --git a/desktop/shared/build.gradle.kts b/desktop/shared/build.gradle.kts index dbc614a..5179e1e 100644 --- a/desktop/shared/build.gradle.kts +++ b/desktop/shared/build.gradle.kts @@ -2,3 +2,8 @@ plugins{ id(MyPlugins.kotlin) id(MyPlugins.composeDesktop) } + +dependencies { + implementation(project(":shared:app-utils")) + implementation(project(":shared:utils")) +} \ No newline at end of file diff --git a/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/DesktopUtils.kt b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/DesktopUtils.kt new file mode 100644 index 0000000..94d360b --- /dev/null +++ b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/DesktopUtils.kt @@ -0,0 +1,23 @@ +package ir.amirab.util.desktop + +import ir.amirab.util.desktop.linux.LinuxUtils +import ir.amirab.util.desktop.mac.MacOSUtils +import ir.amirab.util.desktop.windows.WindowsUtils +import ir.amirab.util.platform.Platform + + +interface DesktopUtils { + fun openSystemProxySettings() + + companion object : DesktopUtils by getDesktopUtilOfCurrentOS() +} + +private fun getDesktopUtilOfCurrentOS(): DesktopUtils { + val platform = Platform.getCurrentPlatform() as Platform.Desktop + return when (platform) { + Platform.Desktop.Windows -> WindowsUtils() + Platform.Desktop.MacOS -> MacOSUtils() + Platform.Desktop.Linux -> LinuxUtils() + } +} + diff --git a/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/linux/LinuxUtils.kt b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/linux/LinuxUtils.kt new file mode 100644 index 0000000..f63311c --- /dev/null +++ b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/linux/LinuxUtils.kt @@ -0,0 +1,31 @@ +package ir.amirab.util.desktop.linux + +import ir.amirab.util.desktop.DesktopUtils +import ir.amirab.util.execAndWait + +class LinuxUtils : DesktopUtils { + override fun openSystemProxySettings() { + val desktopEnv = System.getenv("XDG_CURRENT_DESKTOP") + when { + desktopEnv?.contains("GNOME") ?: false -> { + execAndWait( + arrayOf( + "gnome-control-center network" + ) + ) + } + + desktopEnv?.contains("KDE") ?: false -> { + execAndWait( + arrayOf( + "systemsettings5 proxy" + ) + ) + } + + else -> { + println("Can't open System Proxy Settings: Unsupported desktop environment: $desktopEnv") + } + } + } +} \ No newline at end of file diff --git a/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/mac/MacOSUtils.kt b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/mac/MacOSUtils.kt new file mode 100644 index 0000000..ce6405b --- /dev/null +++ b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/mac/MacOSUtils.kt @@ -0,0 +1,12 @@ +package ir.amirab.util.desktop.mac + +import ir.amirab.util.desktop.DesktopUtils +import ir.amirab.util.execAndWait + +class MacOSUtils : DesktopUtils { + override fun openSystemProxySettings() { + execAndWait( + arrayOf("open /System/Library/PreferencePanes/Network.prefPane") + ) + } +} \ No newline at end of file diff --git a/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/windows/WindowsUtils.kt b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/windows/WindowsUtils.kt new file mode 100644 index 0000000..47f2548 --- /dev/null +++ b/desktop/shared/src/main/kotlin/ir/amirab/util/desktop/windows/WindowsUtils.kt @@ -0,0 +1,20 @@ +package ir.amirab.util.desktop.windows + +import ir.amirab.util.desktop.DesktopUtils +import ir.amirab.util.execAndWait + +class WindowsUtils : DesktopUtils { + override fun openSystemProxySettings() { + val result = execAndWait( + arrayOf( + "cmd", "/c", "start", + "ms-settings:network-proxy", + ) + ) + if (!result) { + arrayOf( + "rundll32.exe shell32.dll,Control_RunDLL inetcpl.cpl,,4" + ) + } + } +} \ No newline at end of file diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/OkHttpDownloaderClient.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/OkHttpDownloaderClient.kt index 8d9a07a..04b1ffa 100644 --- a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/OkHttpDownloaderClient.kt +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/OkHttpDownloaderClient.kt @@ -1,12 +1,19 @@ package ir.amirab.downloader.connection +import ir.amirab.downloader.connection.proxy.ProxyStrategy +import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider +import ir.amirab.downloader.connection.proxy.ProxyType import ir.amirab.downloader.connection.response.ResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.utils.await import okhttp3.* +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector class OkHttpDownloaderClient( private val okHttpClient: OkHttpClient, + private val proxyStrategyProvider: ProxyStrategyProvider, ) : DownloaderClient() { private fun newCall( downloadCredentials: IDownloadCredentials, @@ -15,38 +22,84 @@ class OkHttpDownloaderClient( extraBuilder: Request.Builder.() -> Unit, ): Call { val rangeHeader = createRangeHeader(start, end) - return okHttpClient.newCall( - Request.Builder() - .url(downloadCredentials.link) - .apply { - defaultHeadersInFirst().forEach { (k, v) -> - header(k, v) - } - downloadCredentials.headers - ?.filter { - //OkHttp handles this header and if we override it, - //makes redirected links to have this "Host" instead of their own!, and cause error - !it.key.equals("Host", true) + return okHttpClient + .applyProxy(downloadCredentials) + .newCall( + Request.Builder() + .url(downloadCredentials.link) + .apply { + defaultHeadersInFirst().forEach { (k, v) -> + header(k, v) } - ?.forEach { (k, v) -> + downloadCredentials.headers + ?.filter { + //OkHttp handles this header and if we override it, + //makes redirected links to have this "Host" instead of their own!, and cause error + !it.key.equals("Host", true) + } + ?.forEach { (k, v) -> + header(k, v) + } + defaultHeadersInLast().forEach { (k, v) -> header(k, v) } - defaultHeadersInLast().forEach { (k, v) -> - header(k, v) - } - val username = downloadCredentials.username - val password = downloadCredentials.password - if (username?.isNotBlank() == true && password?.isNotBlank() == true) { - header("Authorization", Credentials.basic(username, password)) - } - downloadCredentials.userAgent?.let { userAgent -> - header("User-Agent", userAgent) + val username = downloadCredentials.username + val password = downloadCredentials.password + if (username?.isNotBlank() == true && password?.isNotBlank() == true) { + header("Authorization", Credentials.basic(username, password)) + } + downloadCredentials.userAgent?.let { userAgent -> + header("User-Agent", userAgent) + } } - } - .apply(extraBuilder) - .header(rangeHeader.first, rangeHeader.second) - .build() - ) + .apply(extraBuilder) + .header(rangeHeader.first, rangeHeader.second) + .build() + ) + } + + private fun OkHttpClient.applyProxy( + downloadCredentials: IDownloadCredentials, + ): OkHttpClient { + return when ( + val strategy = proxyStrategyProvider.getProxyStrategyFor(downloadCredentials.link) + ) { + ProxyStrategy.Direct -> return this + ProxyStrategy.UseSystem -> { + newBuilder() + .proxySelector(ProxySelector.getDefault()) + .build() + } + + is ProxyStrategy.ManualProxy -> { + val proxy = strategy.proxy + return newBuilder() + .proxy( + Proxy( + when (proxy.type) { + ProxyType.HTTP -> Proxy.Type.HTTP + ProxyType.SOCKS -> Proxy.Type.SOCKS + }, + InetSocketAddress(proxy.host, proxy.port) + ) + ).let { + if (proxy.username != null && proxy.type == ProxyType.HTTP) { + it.proxyAuthenticator { _, r -> + val credentials = Credentials.basic( + proxy.username, + proxy.password.orEmpty() + ) + r.request + .newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } + } else { + it + } + }.build() + } + } } diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/Proxy.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/Proxy.kt new file mode 100644 index 0000000..e202b18 --- /dev/null +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/Proxy.kt @@ -0,0 +1,22 @@ +package ir.amirab.downloader.connection.proxy + +import kotlinx.serialization.Serializable + +@Serializable +data class Proxy( + val type: ProxyType, + val host: String, + val port: Int, + val username: String?, + val password: String?, +) { + companion object { + fun default() = Proxy( + type = ProxyType.HTTP, + host = "127.0.0.1", + port = 2080, + username = null, + password = null, + ) + } +} \ No newline at end of file diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategy.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategy.kt new file mode 100644 index 0000000..3eb9438 --- /dev/null +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategy.kt @@ -0,0 +1,7 @@ +package ir.amirab.downloader.connection.proxy + +sealed interface ProxyStrategy { + data object Direct : ProxyStrategy + data object UseSystem : ProxyStrategy + data class ManualProxy(val proxy: Proxy) : ProxyStrategy +} \ No newline at end of file diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategyProvider.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategyProvider.kt new file mode 100644 index 0000000..2b9ac49 --- /dev/null +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategyProvider.kt @@ -0,0 +1,5 @@ +package ir.amirab.downloader.connection.proxy + +interface ProxyStrategyProvider { + fun getProxyStrategyFor(url: String): ProxyStrategy +} diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyType.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyType.kt new file mode 100644 index 0000000..af3a7eb --- /dev/null +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/connection/proxy/ProxyType.kt @@ -0,0 +1,11 @@ +package ir.amirab.downloader.connection.proxy + +import kotlinx.serialization.SerialName + +enum class ProxyType { + @SerialName("http") + HTTP, + + @SerialName("socks") + SOCKS; +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt index 0b11625..0039dc2 100644 --- a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.fromUri +import ir.amirab.util.wildcardMatch import kotlinx.serialization.Serializable /** @@ -47,25 +48,14 @@ data class Category( return true } return acceptedUrlPatterns.any { - test( - patten = it, + wildcardMatch( + pattern = it, input = url ) } } } -private fun test( - patten: String, - input: String, -): Boolean { - return patten - .split("*") - .joinToString(".*") { Regex.escape(it) } - .toRegex() - .containsMatchIn(input) -} - fun Category.iconSource(): IconSource? { return IconSource.fromUri(icon) } diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/IProxyStorage.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/IProxyStorage.kt new file mode 100644 index 0000000..22f84fd --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/IProxyStorage.kt @@ -0,0 +1,7 @@ +package com.abdownloadmanager.utils.proxy + +import kotlinx.coroutines.flow.MutableStateFlow + +interface IProxyStorage { + val proxyDataFlow: MutableStateFlow +} diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/Proxy.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/Proxy.kt new file mode 100644 index 0000000..4b8c9b8 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/Proxy.kt @@ -0,0 +1,55 @@ +package com.abdownloadmanager.utils.proxy + +import ir.amirab.downloader.connection.proxy.Proxy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProxyRules( + val excludeURLPatterns: List, +) + +@Serializable +data class ProxyWithRules( + val proxy: Proxy, + val rules: ProxyRules, +) + +enum class ProxyMode { + @SerialName("direct") + Direct, + + @SerialName("system") + UseSystem, + + @SerialName("manual") + Manual; + + companion object { + fun usableValues(): List { + // UseSystem not works as expected + // so we filter it for now. + return listOf( + Direct, + Manual, + ) + } + } +} + +// for persisting in storage +@Serializable +data class ProxyData( + val proxyMode: ProxyMode, + val proxyWithRules: ProxyWithRules, +) { + companion object { + fun default() = ProxyData( + proxyMode = ProxyMode.Direct, + proxyWithRules = ProxyWithRules( + proxy = Proxy.default(), + rules = ProxyRules(emptyList()) + ) + ) + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/ProxyManager.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/ProxyManager.kt new file mode 100644 index 0000000..d0155a6 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/proxy/ProxyManager.kt @@ -0,0 +1,72 @@ +package com.abdownloadmanager.utils.proxy + +import ir.amirab.downloader.connection.proxy.Proxy +import ir.amirab.downloader.connection.proxy.ProxyStrategy +import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider +import ir.amirab.downloader.connection.proxy.ProxyType +import ir.amirab.util.wildcardMatch +import java.net.Authenticator +import java.net.PasswordAuthentication + +class ProxyManager( + val storage: IProxyStorage, +) : ProxyStrategyProvider { + val proxyData = storage.proxyDataFlow + + init { + val mySocksProxyAuthenticator = MySocksProxyAuthenticator { proxyData.value.proxyWithRules.proxy } + Authenticator.setDefault(mySocksProxyAuthenticator) + } + + private fun getProxyModeForThisURL(url: String): ProxyStrategy { + val usingProxy = proxyData.value + return when (usingProxy.proxyMode) { + ProxyMode.Direct -> ProxyStrategy.Direct + ProxyMode.UseSystem -> ProxyStrategy.UseSystem + ProxyMode.Manual -> { + val proxyWithRules = usingProxy.proxyWithRules + if (shouldUseProxyFor(url, proxyWithRules.rules)) { + ProxyStrategy.ManualProxy(proxyWithRules.proxy) + } else { + ProxyStrategy.Direct + } + } + } + } + + private fun shouldUseProxyFor( + url: String, + rules: ProxyRules, + ): Boolean { + val isInExcludeList = rules.excludeURLPatterns.any { + wildcardMatch(it, url) + } + return !isInExcludeList + } + + override fun getProxyStrategyFor(url: String): ProxyStrategy { + return getProxyModeForThisURL(url) + } +} + +/** + * this is used for socks proxy authentication + */ +private class MySocksProxyAuthenticator( + val currentProxy: () -> Proxy, +) : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val proxy = currentProxy() + if (proxy.type == ProxyType.SOCKS && requestingPrompt == "SOCKS authentication") { + if (proxy.host == requestingHost && proxy.port == requestingPort) { + if (proxy.username != null) { + return PasswordAuthentication( + proxy.username, + proxy.password.orEmpty().toCharArray(), + ) + } + } + } + return null + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/base/KotlinSerializationDataStore.kt b/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/KotlinSerializationDataStore.kt similarity index 97% rename from desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/base/KotlinSerializationDataStore.kt rename to shared/config/src/main/kotlin/ir/amirab/util/config/datastore/KotlinSerializationDataStore.kt index 4a8be9c..b3e199f 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/base/KotlinSerializationDataStore.kt +++ b/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/KotlinSerializationDataStore.kt @@ -1,4 +1,4 @@ -package com.abdownloadmanager.desktop.storage.base +package ir.amirab.util.config.datastore import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore diff --git a/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/ConfigDataStore.kt b/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/MapConfigDataStore.kt similarity index 98% rename from shared/config/src/main/kotlin/ir/amirab/util/config/datastore/ConfigDataStore.kt rename to shared/config/src/main/kotlin/ir/amirab/util/config/datastore/MapConfigDataStore.kt index 7ea78dc..8f13cc3 100644 --- a/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/ConfigDataStore.kt +++ b/shared/config/src/main/kotlin/ir/amirab/util/config/datastore/MapConfigDataStore.kt @@ -46,7 +46,7 @@ class MyConfigSerializer( } } -fun createMyConfigPreferences( +fun createMapConfigDatastore( file: File, json: Json, ): DataStore { diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/Exec.kt b/shared/utils/src/main/kotlin/ir/amirab/util/Exec.kt similarity index 92% rename from shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/Exec.kt rename to shared/utils/src/main/kotlin/ir/amirab/util/Exec.kt index 0bbb0bb..f3aff09 100644 --- a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/Exec.kt +++ b/shared/utils/src/main/kotlin/ir/amirab/util/Exec.kt @@ -1,4 +1,4 @@ -package ir.amirab.util.osfileutil +package ir.amirab.util import java.util.concurrent.TimeUnit @@ -8,7 +8,7 @@ import java.util.concurrent.TimeUnit * @param waitFor maximum time allowed process finish ( in milliseconds ) * @return `true` when process exits with `0` exit code, `false` if the process fails with non-zero exit code or execution time exceeds the [waitFor] */ -internal fun execAndWait( +fun execAndWait( command: Array, waitFor: Long = 2_000, ): Boolean { diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/StringUtil.kt b/shared/utils/src/main/kotlin/ir/amirab/util/StringUtil.kt new file mode 100644 index 0000000..c1170f8 --- /dev/null +++ b/shared/utils/src/main/kotlin/ir/amirab/util/StringUtil.kt @@ -0,0 +1,12 @@ +package ir.amirab.util + +fun wildcardMatch( + pattern: String, + input: String, +): Boolean { + return pattern + .split("*") + .joinToString(".*") { Regex.escape(it) } + .toRegex() + .containsMatchIn(input) +} \ No newline at end of file diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/LinuxFileUtils.kt b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/LinuxFileUtils.kt index fe1bdf2..b786593 100644 --- a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/LinuxFileUtils.kt +++ b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/LinuxFileUtils.kt @@ -1,5 +1,6 @@ package ir.amirab.util.osfileutil +import ir.amirab.util.execAndWait import java.io.File internal class LinuxFileUtils : FileUtilsBase() { diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/MacOsFileUtils.kt b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/MacOsFileUtils.kt index 517c340..fe19dcc 100644 --- a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/MacOsFileUtils.kt +++ b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/MacOsFileUtils.kt @@ -1,5 +1,6 @@ package ir.amirab.util.osfileutil +import ir.amirab.util.execAndWait import java.io.File internal class MacOsFileUtils : FileUtilsBase() { diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/WindowsFileUtils.kt b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/WindowsFileUtils.kt index da4d783..1b82e7b 100644 --- a/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/WindowsFileUtils.kt +++ b/shared/utils/src/main/kotlin/ir/amirab/util/osfileutil/WindowsFileUtils.kt @@ -1,5 +1,6 @@ package ir.amirab.util.osfileutil +import ir.amirab.util.execAndWait import java.io.File internal class WindowsFileUtils : FileUtilsBase() {