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 <VIIII4@users.noreply.github.com>
This commit is contained in:
VIIII4andVIIII4 authored and GitHub committed 2026-10-25 12:07:26 -04:00
1 parent 2be08bc67d
commit c6bc8aef04
9 files changed
+100 -1

No files matched your search

@@ -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)
@@ -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 ""
}
}
@@ -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);
@@ -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)
@@ -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 = {},
@@ -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) {
@@ -838,6 +838,7 @@
<string name="setup_download_source_custom">自定义前缀</string>
<string name="setup_download_mirror">镜像站点</string>
<string name="setup_download_prefix">前缀</string>
<string name="setup_download_proxy">HTTP 代理</string>
<string name="setup_tool_files">文件</string>
<string name="setup_tool_files_hint">浏览和管理文件</string>
<string name="setup_tool_protons">Proton 版本</string>
+1
View File
@@ -2420,6 +2420,7 @@
<string name="setup_download_source_custom">Custom prefix</string>
<string name="setup_download_mirror">Site</string>
<string name="setup_download_prefix">Prefix</string>
<string name="setup_download_proxy">HTTP proxy</string>
<string name="flathub_eyebrow">LINUX APPS</string>
<string name="flathub_name">Flathub</string>
<string name="flathub_line">Desktop apps for the Linux desktop, opened from Desktop.</string>
@@ -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"))
}
}