From c6bc8aef04ef9f9b336d015c42562fcc7e7f4aaa Mon Sep 17 00:00:00 2001 From: VIIII4 Date: Mon, 26 Oct 2026 00:07:26 +0800 Subject: [PATCH] Add an optional HTTP proxy for downloads (#1474) * Add an optional HTTP proxy for downloads Downloads only reached GitHub directly or through URL-rewrite mirrors, which does not help where a local proxy is the workable route. Setup now takes a host:port HTTP proxy that every Downloader connection uses, independently of the download source mode. * Reference the Kotlin object instance from Java Java cannot call a Kotlin object member on the class name without @JvmStatic; the surrounding code already uses INSTANCE. * Assert the proxy host literal without a reverse lookup InetSocketAddress.getHostName reverse-resolves on some JVMs (127.0.0.1 became localhost on CI); hostString is the literal. * Pass the app proxy into the guest environment The Steam client installer and the guest package tools fetch with plain curl against their own CDNs, outside Downloader. Export the usual proxy variables for them when the app proxy is set; proot shares the host network, so a localhost proxy is reachable as-is. --------- Co-authored-by: VIIII4 --- .../droiddeck/launcher/FrontEndActivity.kt | 7 ++++ .../droiddeck/launcher/core/DownloadSource.kt | 26 ++++++++++++++ .../droiddeck/launcher/core/Downloader.java | 6 +++- .../launcher/runtime/GuestCommand.kt | 10 ++++++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 2 ++ .../droiddeck/launcher/ui/FrontEndSetup.kt | 12 +++++++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../launcher/core/DownloadSourceProxyTest.kt | 36 +++++++++++++++++++ 9 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index f81680e0f..5e5bf5c9a 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -170,6 +170,7 @@ class FrontEndActivity : ComponentActivity() { private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO) private var downloadMirror by mutableStateOf("") private var downloadPrefix by mutableStateOf("") + private var downloadProxy by mutableStateOf("") private var animationsEnabled by mutableStateOf(true) private var launcherIcon by mutableStateOf(LauncherIcon.DARK) private var gameStoresEnabled by mutableStateOf(true) @@ -768,6 +769,7 @@ class FrontEndActivity : ComponentActivity() { downloadSource = DownloadSource.mode(this) downloadMirror = DownloadSource.mirror(this) downloadPrefix = DownloadSource.custom(this) + downloadProxy = DownloadSource.proxyHostPort(this) DownloadSource.probe() animationsEnabled = SessionPrefs.animationsEnabled(this) LauncherIcon.repair(this, intent?.component) @@ -833,6 +835,7 @@ class FrontEndActivity : ComponentActivity() { downloadSource = downloadSource, downloadMirror = downloadMirror, downloadPrefix = downloadPrefix, + downloadProxy = downloadProxy, animationsEnabled = animationsEnabled, launcherIcon = launcherIcon, gameStoresEnabled = gameStoresEnabled, @@ -1033,6 +1036,10 @@ class FrontEndActivity : ComponentActivity() { DownloadSource.setCustom(this, prefix) downloadPrefix = DownloadSource.custom(this) }, + onDownloadProxy = { value -> + DownloadSource.setProxy(this, value) + downloadProxy = DownloadSource.proxyHostPort(this) + }, onHomeApp = { manageHomeApp() }, onHomeScreen = { on -> HomeApp.setHomeScreenEnabled(this, on) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt index 870e8ed42..b11e3113a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -3,6 +3,8 @@ package com.droiddeck.launcher.core import android.content.Context import android.content.SharedPreferences import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.Proxy import java.net.URL import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -20,6 +22,7 @@ object DownloadSource { private const val KEY_MODE = "mode" private const val KEY_MIRROR = "mirror" private const val KEY_CUSTOM = "custom" + private const val KEY_PROXY = "proxy" private const val KEY_FASTEST = "fastest" private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md" @@ -175,6 +178,22 @@ object DownloadSource { prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply() } + fun proxyHostPort(context: Context): String = prefs(context)?.getString(KEY_PROXY, "") ?: "" + + fun setProxy(context: Context, value: String) { + prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply() + } + + /** An HTTP proxy every Downloader connection uses, or null when none is configured. */ + fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "") + + internal fun proxyOf(value: String): Proxy? { + val host = value.substringBefore(':') + val port = value.substringAfter(':', "").toIntOrNull() ?: return null + if (host.isEmpty()) return null + return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port)) + } + fun reset(context: Context) { lastGood = null prefs(context)?.edit()?.clear()?.apply() @@ -205,4 +224,11 @@ object DownloadSource { if (trimmed.isEmpty() || trimmed.startsWith("http://")) return "" return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed" } + + internal fun normalizeProxy(value: String): String { + val trimmed = value.trim().removePrefix("http://").trimEnd('/') + val host = trimmed.substringBefore(':') + val port = trimmed.substringAfter(':', "").toIntOrNull() ?: 0 + return if (host.isNotEmpty() && port in 1..65535) "$host:$port" else "" + } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 183076188..98e41ab6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.Proxy; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.List; @@ -277,7 +278,10 @@ public final class Downloader { } private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException { - HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + // An explicit Proxy routes the connection through the app setting instead of the system-wide proxy. + Proxy proxy = DownloadSource.INSTANCE.httpProxy(); + HttpURLConnection connection = (HttpURLConnection) (proxy != null + ? new URL(url).openConnection(proxy) : new URL(url).openConnection()); connection.setConnectTimeout(CONNECT_TIMEOUT_MS); connection.setReadTimeout(readTimeoutMs); connection.setInstanceFollowRedirects(true); diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 8b4f14e5a..33317e15b 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime import android.content.Context import android.os.Environment +import com.droiddeck.launcher.core.DownloadSource import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.session.OrphanReaper import com.droiddeck.launcher.session.SessionFiles @@ -45,6 +46,15 @@ object GuestCommand { "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) + // The Steam client installer and the package tools inside the guest fetch over plain curl, + // so the app proxy has to reach them through the environment proot starts them with. + DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort -> + cmd += listOf( + "http_proxy=http://$hostPort", "https_proxy=http://$hostPort", + "HTTP_PROXY=http://$hostPort", "HTTPS_PROXY=http://$hostPort", + "no_proxy=localhost,127.0.0.1,::1", "NO_PROXY=localhost,127.0.0.1,::1", + ) + } fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) } cmd += argv val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index bde8b54f7..41e18b486 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -137,6 +137,7 @@ class FrontEndState( val downloadSource: String = DownloadSource.MODE_AUTO, val downloadMirror: String = "", val downloadPrefix: String = "", + val downloadProxy: String = "", val animationsEnabled: Boolean = true, val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK, val gameStoresEnabled: Boolean = true, @@ -214,6 +215,7 @@ class FrontEndActions( val onDownloadSource: (String) -> Unit = {}, val onDownloadMirror: (String) -> Unit = {}, val onDownloadPrefix: (String) -> Unit = {}, + val onDownloadProxy: (String) -> Unit = {}, val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 8f92dbbc6..f96c0eb2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -174,6 +174,7 @@ internal fun SetupPanel( var showLimitDetails by rememberSaveable { mutableStateOf(false) } var editSgdbKey by remember { mutableStateOf(false) } var editDownloadPrefix by remember { mutableStateOf(false) } + var editDownloadProxy by remember { mutableStateOf(false) } if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false }) if (editDownloadPrefix) TextEntryDialog( @@ -181,6 +182,11 @@ internal fun SetupPanel( onDismiss = { editDownloadPrefix = false }, onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false }, ) + if (editDownloadProxy) TextEntryDialog( + stringResource(R.string.setup_download_proxy), s.downloadProxy, + onDismiss = { editDownloadProxy = false }, + onSave = { a.onDownloadProxy(it); editDownloadProxy = false }, + ) val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } SetupTabs.openLauncher = false @@ -439,6 +445,12 @@ internal fun SetupPanel( onClick = { editDownloadPrefix = true }, ) } + ActionRow( + stringResource(R.string.setup_download_proxy), + s.downloadProxy.takeIf { it.isNotEmpty() }, + stringResource(R.string.mode_sgdb_edit), + onClick = { editDownloadProxy = true }, + ) } } if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a2ca7e857..3004e5b17 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -838,6 +838,7 @@ 自定义前缀 镜像站点 前缀 + HTTP 代理 文件 浏览和管理文件 Proton 版本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0889e5d8b..11dc9fd56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2420,6 +2420,7 @@ Custom prefix Site Prefix + HTTP proxy LINUX APPS Flathub Desktop apps for the Linux desktop, opened from Desktop. diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt new file mode 100644 index 000000000..46725fc7f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.core + +import java.net.InetSocketAddress +import java.net.Proxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DownloadSourceProxyTest { + @Test fun hostAndPortBecomeAnHttpProxy() { + val proxy = DownloadSource.proxyOf("127.0.0.1:9910") + assertEquals(Proxy.Type.HTTP, proxy?.type()) + val address = proxy?.address() as InetSocketAddress + assertEquals("127.0.0.1", address.hostString) + assertEquals(9910, address.port) + } + + @Test fun missingOrInvalidPortIsIgnored() { + assertNull(DownloadSource.proxyOf("")) + assertNull(DownloadSource.proxyOf("127.0.0.1")) + assertNull(DownloadSource.proxyOf("127.0.0.1:")) + assertNull(DownloadSource.proxyOf("127.0.0.1:notaport")) + } + + @Test fun normalizationAcceptsSchemeAndTrailingSlash() { + assertEquals("127.0.0.1:9910", DownloadSource.normalizeProxy(" http://127.0.0.1:9910/ ")) + assertEquals("192.168.1.2:8080", DownloadSource.normalizeProxy("192.168.1.2:8080")) + } + + @Test fun invalidInputDisablesTheProxy() { + assertEquals("", DownloadSource.normalizeProxy("")) + assertEquals("", DownloadSource.normalizeProxy("127.0.0.1")) + assertEquals("", DownloadSource.normalizeProxy("127.0.0.1:70000")) + assertEquals("", DownloadSource.normalizeProxy("http://127.0.0.1:notaport")) + } +}